|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * An auth SMS reads its template row in the organization the send resolves to. |
| 5 | + * |
| 6 | + * `sys_notification_template` is organization-owned (ADR-0131: a row is the |
| 7 | + * organization that authored it). The phone OTP and invitation SMS are |
| 8 | + * deployment-level acts whose send callbacks carry no organization, so |
| 9 | + * `AuthManager.renderPhoneSmsBody` hands the resolver the organization that |
| 10 | + * act resolves to, `tenancy.defaultOrgId()` (ADR-0093 D3), and every template |
| 11 | + * read is keyed by it (`phone-sms-texts.ts`, "Which organization's row"): |
| 12 | + * |
| 13 | + * - `single`: the Default Organization's row decides the text; |
| 14 | + * - `isolated` and `group`: `defaultOrgId()` is `null`, so no row is read and |
| 15 | + * the built-in text is sent — no organization's row decides it. |
| 16 | + * |
| 17 | + * Pinned through the real `AuthManager` (the public invitation send and the |
| 18 | + * phone OTP send), the real tenancy service, and a real `ObjectQL` over |
| 19 | + * `SqlDriver` (better-sqlite3) holding two organizations, each of which |
| 20 | + * authored its own rows for both auth topics. |
| 21 | + */ |
| 22 | + |
| 23 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 24 | +import { ObjectQL } from '@objectstack/objectql'; |
| 25 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 26 | +import { NotificationTemplate } from '@objectstack/service-messaging'; |
| 27 | +import { AuthManager } from './auth-manager.js'; |
| 28 | +import { authIdentityObjects } from './manifest.js'; |
| 29 | +import { createTenancyService } from './tenancy-service.js'; |
| 30 | +import { PHONE_SMS_TOPICS, builtinPhoneSmsBody, interpolatePhoneSms } from './phone-sms-texts.js'; |
| 31 | + |
| 32 | +const SECRET = 'test-secret-at-least-32-chars-long-24865'; |
| 33 | +const ORIGIN = 'http://localhost:3000'; |
| 34 | +const PHONE = '+15550124865'; |
| 35 | +const CODE = '482913'; |
| 36 | +const TEMPLATE_OBJECT = 'sys_notification_template'; |
| 37 | +const SYSTEM = { context: { isSystem: true } } as never; |
| 38 | + |
| 39 | +type Posture = 'single' | 'isolated' | 'group'; |
| 40 | + |
| 41 | +const engines: ObjectQL[] = []; |
| 42 | +afterEach(async () => { |
| 43 | + while (engines.length) { |
| 44 | + try { await engines.pop()!.destroy(); } catch { /* noop */ } |
| 45 | + } |
| 46 | +}); |
| 47 | + |
| 48 | +/** |
| 49 | + * A real engine on `posture` holding the Default Organization and one more, |
| 50 | + * each of which authored its own row for both auth SMS topics, and the |
| 51 | + * recipient's account. |
| 52 | + */ |
| 53 | +async function bootEngine(posture: Posture): Promise<ObjectQL> { |
| 54 | + const engine = new ObjectQL(); |
| 55 | + engines.push(engine); |
| 56 | + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); |
| 57 | + await engine.init(); |
| 58 | + for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); |
| 59 | + engine.registry.registerObject(NotificationTemplate as never, '@objectstack/service-messaging'); |
| 60 | + await engine.syncSchemas(); |
| 61 | + engine.setTenancyPostureProvider(() => posture); |
| 62 | + |
| 63 | + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYSTEM); |
| 64 | + await engine.insert('sys_organization', { id: 'org_other', name: 'Other', slug: 'other' }, SYSTEM); |
| 65 | + for (const organization of ['org_default', 'org_other']) { |
| 66 | + for (const topic of [PHONE_SMS_TOPICS.otp, PHONE_SMS_TOPICS.invite]) { |
| 67 | + // Written as that organization's write lands. |
| 68 | + await engine.insert( |
| 69 | + TEMPLATE_OBJECT, |
| 70 | + { topic, channel: 'sms', locale: 'en', body: `${organization} ${topic} {{code}}`, format: 'text', is_active: true }, |
| 71 | + { context: { isSystem: true, tenantId: organization } } as never, |
| 72 | + ); |
| 73 | + } |
| 74 | + } |
| 75 | + await engine.insert('sys_user', { id: 'usr_recipient', name: 'Recipient', email: 'recipient@example.test', phone_number: PHONE, locale: 'en' }, SYSTEM); |
| 76 | + return engine; |
| 77 | +} |
| 78 | + |
| 79 | +/** What each template row is stored with — the fixture's own control. */ |
| 80 | +async function storedOrganizations(engine: ObjectQL): Promise<string[]> { |
| 81 | + const rows = (await engine.find(TEMPLATE_OBJECT, { fields: ['topic', 'organization_id'] }, SYSTEM)) ?? []; |
| 82 | + return rows.map((r: Record<string, unknown>) => `${String(r.organization_id ?? 'NULL')}:${String(r.topic)}`).sort(); |
| 83 | +} |
| 84 | + |
| 85 | +/** The real manager over `engine`, with the real tenancy service and an SMS transport that records each body. */ |
| 86 | +function bootManager(engine: ObjectQL, posture: Posture) { |
| 87 | + const tenancy = createTenancyService({ |
| 88 | + requested: posture, |
| 89 | + probeIsolation: () => true, |
| 90 | + getEngine: () => engine, |
| 91 | + logger: { info: () => {}, warn: () => {} }, |
| 92 | + }); |
| 93 | + const bodies: string[] = []; |
| 94 | + const manager = new AuthManager({ |
| 95 | + secret: SECRET, |
| 96 | + baseUrl: ORIGIN, |
| 97 | + dataEngine: engine, |
| 98 | + plugins: { phoneNumber: true }, |
| 99 | + smsService: { |
| 100 | + async send(input: { body: string }) { bodies.push(input.body); return { status: 'sent', messageId: 'm' }; }, |
| 101 | + isConfigured: () => true, |
| 102 | + }, |
| 103 | + getTenancy: () => tenancy, |
| 104 | + } as never); |
| 105 | + return { manager, bodies, tenancy }; |
| 106 | +} |
| 107 | + |
| 108 | +/** Both auth SMS sends: the public invitation, and the phone OTP the sign-in callbacks drive. */ |
| 109 | +async function sendBoth(manager: AuthManager): Promise<void> { |
| 110 | + await manager.sendPhoneInviteSms(PHONE); |
| 111 | + await (manager as unknown as { deliverPhoneOtp(phone: string, code: string): Promise<void> }).deliverPhoneOtp(PHONE, CODE); |
| 112 | +} |
| 113 | + |
| 114 | +/** The built-in `en` text for `topic` with every hole open — the invitation's URLs are the manager's own. */ |
| 115 | +function builtinShape(topic: string): RegExp { |
| 116 | + const escaped = builtinPhoneSmsBody(topic, 'en') |
| 117 | + .split(/\{\{\s*[\w.$]+\s*\}\}/) |
| 118 | + .map((literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); |
| 119 | + return new RegExp(`^${escaped.join('.+')}$`); |
| 120 | +} |
| 121 | + |
| 122 | +const EXPECTED_ROWS = [ |
| 123 | + `org_default:${PHONE_SMS_TOPICS.invite}`, `org_default:${PHONE_SMS_TOPICS.otp}`, |
| 124 | + `org_other:${PHONE_SMS_TOPICS.invite}`, `org_other:${PHONE_SMS_TOPICS.otp}`, |
| 125 | +]; |
| 126 | + |
| 127 | +describe("an auth SMS reads its template row in the organization the send resolves to", () => { |
| 128 | + it("single: the Default Organization's row decides the invitation and the OTP text", async () => { |
| 129 | + const engine = await bootEngine('single'); |
| 130 | + expect(await storedOrganizations(engine)).toEqual(EXPECTED_ROWS); |
| 131 | + const { manager, bodies, tenancy } = bootManager(engine, 'single'); |
| 132 | + expect(await tenancy.defaultOrgId()).toBe('org_default'); |
| 133 | + |
| 134 | + await sendBoth(manager); |
| 135 | + |
| 136 | + expect(bodies).toEqual([ |
| 137 | + `org_default ${PHONE_SMS_TOPICS.invite} `, |
| 138 | + `org_default ${PHONE_SMS_TOPICS.otp} ${CODE}`, |
| 139 | + ]); |
| 140 | + }); |
| 141 | + |
| 142 | + for (const posture of ['isolated', 'group'] as const) { |
| 143 | + it(`${posture}: the built-in text is sent, and no organization's row decides it`, async () => { |
| 144 | + const engine = await bootEngine(posture); |
| 145 | + expect(await storedOrganizations(engine)).toEqual(EXPECTED_ROWS); |
| 146 | + const { manager, bodies, tenancy } = bootManager(engine, posture); |
| 147 | + expect(await tenancy.defaultOrgId()).toBeNull(); |
| 148 | + |
| 149 | + await sendBoth(manager); |
| 150 | + |
| 151 | + expect(bodies).toHaveLength(2); |
| 152 | + expect(bodies[1]).toBe( |
| 153 | + interpolatePhoneSms(builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'en'), { code: CODE, appName: 'ObjectStack', minutes: 5 }), |
| 154 | + ); |
| 155 | + expect(bodies[0]).toMatch(builtinShape(PHONE_SMS_TOPICS.invite)); |
| 156 | + for (const body of bodies) expect(body).not.toMatch(/org_default|org_other/); |
| 157 | + }); |
| 158 | + } |
| 159 | +}); |
0 commit comments