|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [ADR-0131 D9] The organization census of a self-registration on a WALLED |
| 4 | +// boot (`group` and `isolated`), end to end on the real stack with the audit |
| 5 | +// and email plugins composed together. The `single` leg is |
| 6 | +// `signup-organization-census.dogfood.test.ts`; this file is its walled |
| 7 | +// sibling, one boot per posture. |
| 8 | +// |
| 9 | +// The journey is the whole sign-up a walled deployment sees: a stranger |
| 10 | +// self-registers (the verification mail is sent), creates their organization |
| 11 | +// (their first membership), and invites a second stranger (the invitation |
| 12 | +// mail is sent), who self-registers and accepts it (their first membership). |
| 13 | +// Three framework writers act on tenant-column objects along the way, and on |
| 14 | +// a wall none of them may fall back to an organization-less row: |
| 15 | +// |
| 16 | +// - the self-registration grant (`sys_user_permission_set`) settles on each |
| 17 | +// registrant's FIRST MEMBERSHIP, owned by that membership's organization |
| 18 | +// (ADR-0131 D3) — never at sign-up, when there is no organization yet; |
| 19 | +// - the activity writer (`sys_activity`) stamps the organization of the |
| 20 | +// record it is about, and writes NO row about a record that has none |
| 21 | +// (ADR-0131 D9; ruling of record 6257510973 item 3, Q3 A); |
| 22 | +// - the verification mail is identity mail, written to |
| 23 | +// `sys_identity_email`, which carries no organization column (ADR-0131 |
| 24 | +// D7; ruling of record 6257510973, Q2 A); the invitation mail is tenant |
| 25 | +// mail, written to `sys_email`, owned by the inviting organization. |
| 26 | +// |
| 27 | +// The census reads the STORE, not the registry: every table of the sqlite |
| 28 | +// database that carries an `organization_id` column — activity shards and |
| 29 | +// tables no object names included — is derived from the schema at run time, |
| 30 | +// so a new table joins the census on its own. Since 18.x the engine creates a |
| 31 | +// tenant column NOT NULL, so "0 NULL rows" alone could hold vacuously (a |
| 32 | +// refused write leaves no row, and no NULL either); every writer's own row is |
| 33 | +// therefore also required to EXIST and to name the organization the user |
| 34 | +// joined. |
| 35 | +// |
| 36 | +// The boot is `multiTenant: 'posture-only'`: a real, non-degraded walled |
| 37 | +// posture. Every writer here keys on the posture (whether an organization is |
| 38 | +// derivable), never on row isolation, so the stand-in's lack of a row wall |
| 39 | +// measures nothing away. `OS_TENANCY_POSTURE` selects `group`. |
| 40 | + |
| 41 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 42 | +import crmStack from '@objectstack/example-crm'; |
| 43 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 44 | +import { EmailServicePlugin } from '@objectstack/plugin-email'; |
| 45 | +import { AuditPlugin } from '@objectstack/plugin-audit'; |
| 46 | + |
| 47 | +const SYS = { context: { isSystem: true } }; |
| 48 | +const SET = 'member_default'; |
| 49 | +const PASSWORD = 'Census-Passw0rd!'; |
| 50 | + |
| 51 | +/** A transport that accepts every message, so every mail persists as sent. */ |
| 52 | +const acceptingTransport = { |
| 53 | + async send() { |
| 54 | + return { messageId: `census-${Math.random().toString(36).slice(2, 10)}` }; |
| 55 | + }, |
| 56 | +}; |
| 57 | + |
| 58 | +type Row = Record<string, unknown>; |
| 59 | + |
| 60 | +for (const posture of ['group', 'isolated'] as const) { |
| 61 | + describe(`[ADR-0131 D9] ${posture}: a walled self-registration leaves no organization-less row`, () => { |
| 62 | + const REGISTRANT = `census-registrant-${posture}@dogfood.test`; |
| 63 | + const INVITEE = `census-invitee-${posture}@dogfood.test`; |
| 64 | + const SLUG = `census-${posture}`; |
| 65 | + let stack: VerifyStack; |
| 66 | + let ql: any; |
| 67 | + let orgId: string; |
| 68 | + let registrantId: string; |
| 69 | + let inviteeId: string; |
| 70 | + let invitationId: string; |
| 71 | + /** `table/id` of every row the store held on a tenant-column table BEFORE the journey. */ |
| 72 | + const beforeJourney = new Set<string>(); |
| 73 | + const previous = process.env.OS_TENANCY_POSTURE; |
| 74 | + |
| 75 | + /** A raw statement on the default datasource — the store as it is, below every engine read path. */ |
| 76 | + const sql = async (statement: string): Promise<Row[]> => { |
| 77 | + const rows = await ql.getDriverForObject('sys_user').execute(statement); |
| 78 | + return Array.isArray(rows) ? (rows as Row[]) : []; |
| 79 | + }; |
| 80 | + /** Every table of the store that carries an `organization_id` column, read from the schema now. */ |
| 81 | + const tenantColumnTables = async (): Promise<string[]> => { |
| 82 | + const tables = (await sql("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")).map((r) => String(r.name)); |
| 83 | + const out: string[] = []; |
| 84 | + for (const table of tables) { |
| 85 | + const columns = await sql(`PRAGMA table_info("${table}")`); |
| 86 | + if (columns.some((c) => c.name === 'organization_id')) out.push(table); |
| 87 | + } |
| 88 | + return out; |
| 89 | + }; |
| 90 | + /** Every row of every tenant-column table, keyed `table/id`. */ |
| 91 | + const storeRows = async (): Promise<Map<string, Row & { table: string }>> => { |
| 92 | + const out = new Map<string, Row & { table: string }>(); |
| 93 | + for (const table of await tenantColumnTables()) { |
| 94 | + for (const row of await sql(`SELECT * FROM "${table}"`)) out.set(`${table}/${String(row.id)}`, { ...row, table }); |
| 95 | + } |
| 96 | + return out; |
| 97 | + }; |
| 98 | + const journeyRows = async () => [...(await storeRows()).entries()].filter(([key]) => !beforeJourney.has(key)).map(([, row]) => row); |
| 99 | + const userIdOf = async (email: string): Promise<string> => { |
| 100 | + const user = await ql.findOne('sys_user', { where: { email }, ...SYS }); |
| 101 | + expect(user?.id, `${email} exists`).toBeTruthy(); |
| 102 | + return String(user.id); |
| 103 | + }; |
| 104 | + /** Self-register through the public door; the address is then marked verified in place, so the user can sign in. */ |
| 105 | + const selfRegister = async (email: string, name: string): Promise<{ id: string; token: string }> => { |
| 106 | + const signedUp = await stack.api('/auth/sign-up/email', { |
| 107 | + method: 'POST', |
| 108 | + headers: { 'Content-Type': 'application/json' }, |
| 109 | + body: JSON.stringify({ email, password: PASSWORD, name }), |
| 110 | + }); |
| 111 | + expect(signedUp.status, `sign-up ${email}: ${await signedUp.clone().text()}`).toBe(200); |
| 112 | + const id = await userIdOf(email); |
| 113 | + await ql.update('sys_user', { id, email_verified: true }, SYS); |
| 114 | + return { id, token: await stack.signIn(email, PASSWORD) }; |
| 115 | + }; |
| 116 | + |
| 117 | + beforeAll(async () => { |
| 118 | + process.env.OS_TENANCY_POSTURE = posture; |
| 119 | + stack = await bootStack(crmStack as never, { |
| 120 | + multiTenant: 'posture-only', |
| 121 | + extraPlugins: [new AuditPlugin(), new EmailServicePlugin({ |
| 122 | + transport: acceptingTransport as never, |
| 123 | + defaultFrom: { address: 'no-reply@example.test', name: 'Census' }, |
| 124 | + })], |
| 125 | + }); |
| 126 | + const adminToken = await stack.signIn(); |
| 127 | + ql = await stack.kernel.getServiceAsync<any>('objectql'); |
| 128 | + |
| 129 | + // An open audience: a stranger self-registers, is owed the declared set, |
| 130 | + // and is sent the verification mail the open posture requires. |
| 131 | + const opened = await stack.raw('/api/settings/auth', { |
| 132 | + method: 'PUT', |
| 133 | + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` }, |
| 134 | + body: JSON.stringify({ values: { audience_posture: 'open', audience_self_registration_permission_set: SET } }), |
| 135 | + }); |
| 136 | + expect(opened.status, `open the audience: ${await opened.clone().text()}`).toBe(200); |
| 137 | + |
| 138 | + // The census is of what the JOURNEY writes; the boot's own rows are set |
| 139 | + // aside here, by table and id (they are still read by the whole-store |
| 140 | + // NULL census below). |
| 141 | + for (const key of (await storeRows()).keys()) beforeJourney.add(key); |
| 142 | + |
| 143 | + // 1. The registrant self-registers and creates their organization. |
| 144 | + const registrant = await selfRegister(REGISTRANT, 'Census Registrant'); |
| 145 | + registrantId = registrant.id; |
| 146 | + const created = await stack.apiAs(registrant.token, 'POST', '/auth/organization/create', { name: `Census ${posture}`, slug: SLUG }); |
| 147 | + expect(created.status, `organization/create: ${await created.clone().text()}`).toBe(200); |
| 148 | + orgId = String(((await created.json()) as { id?: string }).id ?? ''); |
| 149 | + expect(orgId, 'the organization was created').not.toBe(''); |
| 150 | + |
| 151 | + // 2. They invite a second stranger, who self-registers and accepts. |
| 152 | + const invited = await stack.apiAs(registrant.token, 'POST', '/auth/organization/invite-member', { |
| 153 | + email: INVITEE, role: 'member', organizationId: orgId, |
| 154 | + }); |
| 155 | + expect(invited.status, `invite-member: ${await invited.clone().text()}`).toBe(200); |
| 156 | + invitationId = String(((await invited.json()) as { id?: string }).id ?? ''); |
| 157 | + expect(invitationId, 'the invitation was issued').not.toBe(''); |
| 158 | + const invitee = await selfRegister(INVITEE, 'Census Invitee'); |
| 159 | + inviteeId = invitee.id; |
| 160 | + const accepted = await stack.apiAs(invitee.token, 'POST', '/auth/organization/accept-invitation', { invitationId }); |
| 161 | + expect(accepted.status, `accept-invitation: ${await accepted.clone().text()}`).toBe(200); |
| 162 | + }, 180_000); |
| 163 | + |
| 164 | + afterAll(async () => { |
| 165 | + await stack?.stop?.(); |
| 166 | + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; |
| 167 | + else process.env.OS_TENANCY_POSTURE = previous; |
| 168 | + }); |
| 169 | + |
| 170 | + it('guard: a real walled posture, and each registrant\'s first membership is the organization of the journey', async () => { |
| 171 | + const tenancy = await stack.kernel.getServiceAsync<{ posture: string; degraded: boolean; isolationActive: boolean }>('tenancy'); |
| 172 | + expect(tenancy.posture).toBe(posture); |
| 173 | + expect(tenancy.degraded).toBe(false); |
| 174 | + expect(tenancy.isolationActive).toBe(true); |
| 175 | + for (const userId of [registrantId, inviteeId]) { |
| 176 | + const memberships = await ql.find('sys_member', { where: { user_id: userId }, ...SYS }); |
| 177 | + expect(memberships.map((m: Row) => m.organization_id), `${userId}: memberships`).toEqual([orgId]); |
| 178 | + } |
| 179 | + // The census's own reach: the store's tenant-column tables include every |
| 180 | + // one the three writers write. |
| 181 | + const tables = await tenantColumnTables(); |
| 182 | + for (const table of ['sys_user_permission_set', 'sys_email', 'sys_member', 'sys_invitation']) expect(tables).toContain(table); |
| 183 | + expect(tables.some((t) => t === 'sys_activity' || t.startsWith('sys_activity__')), 'an activity table carries the column').toBe(true); |
| 184 | + }); |
| 185 | + |
| 186 | + it('the whole store holds 0 rows without an organization on every table that carries the column', async () => { |
| 187 | + const orgLess = [...(await storeRows()).values()] |
| 188 | + .filter((r) => r.organization_id == null || r.organization_id === '') |
| 189 | + .map((r) => `${r.table}/${String(r.id)}`); |
| 190 | + expect(orgLess, 'rows without an organization').toEqual([]); |
| 191 | + }); |
| 192 | + |
| 193 | + it('every row the journey wrote on a tenant-column table names the organization the users joined', async () => { |
| 194 | + const written = await journeyRows(); |
| 195 | + expect(written.length, 'the journey wrote tenant rows').toBeGreaterThan(0); |
| 196 | + const foreign = written.filter((r) => r.organization_id !== orgId).map((r) => `${r.table}/${String(r.id)} -> ${String(r.organization_id)}`); |
| 197 | + expect(foreign, 'rows owned by an organization other than the journey\'s').toEqual([]); |
| 198 | + }); |
| 199 | + |
| 200 | + it('the grant writer: each registrant holds exactly one declared grant, owned by their first membership', async () => { |
| 201 | + for (const userId of [registrantId, inviteeId]) { |
| 202 | + const grants = await ql.find('sys_user_permission_set', { where: { user_id: userId }, ...SYS }); |
| 203 | + expect(grants.map((g: Row) => [g.permission_set, g.organization_id]), `${userId}: grants`).toEqual([[SET, orgId]]); |
| 204 | + } |
| 205 | + }); |
| 206 | + |
| 207 | + it('the activity writer: activity about each grant and about the invitation mail names the organization', async () => { |
| 208 | + const written = await journeyRows(); |
| 209 | + const activity = written.filter((r) => r.table === 'sys_activity' || r.table.startsWith('sys_activity__')); |
| 210 | + const grants = await ql.find('sys_user_permission_set', { where: { user_id: { $in: [registrantId, inviteeId] } }, ...SYS }); |
| 211 | + expect(grants.length).toBe(2); |
| 212 | + for (const grant of grants) { |
| 213 | + const about = activity.filter((a) => a.object_name === 'sys_user_permission_set' && a.record_id === grant.id); |
| 214 | + expect(about.length, `activity about the grant ${String(grant.id)}`).toBeGreaterThan(0); |
| 215 | + expect(about.map((a) => a.organization_id)).toEqual(about.map(() => orgId)); |
| 216 | + } |
| 217 | + const [invitationMail] = await ql.find('sys_email', { where: { related_object: 'sys_invitation', related_id: invitationId }, ...SYS }); |
| 218 | + expect(invitationMail?.id, 'the invitation mail persisted').toBeTruthy(); |
| 219 | + const aboutMail = activity.filter((a) => a.object_name === 'sys_email' && a.record_id === invitationMail.id); |
| 220 | + expect(aboutMail.length, 'activity about the invitation mail').toBeGreaterThan(0); |
| 221 | + expect(aboutMail.map((a) => a.organization_id)).toEqual(aboutMail.map(() => orgId)); |
| 222 | + // A record with no organization of its own (the registrant) on a wall |
| 223 | + // takes none: no activity row about it, never a NULL one. |
| 224 | + expect(activity.filter((a) => a.object_name === 'sys_user' && [registrantId, inviteeId].includes(String(a.record_id)))).toEqual([]); |
| 225 | + }); |
| 226 | + |
| 227 | + it('the mail writers: the invitation is tenant mail owned by the organization; each verification mail is identity mail', async () => { |
| 228 | + const [invitationMail] = await ql.find('sys_email', { where: { related_object: 'sys_invitation', related_id: invitationId }, ...SYS }); |
| 229 | + expect(invitationMail?.organization_id).toBe(orgId); |
| 230 | + for (const userId of [registrantId, inviteeId]) { |
| 231 | + const identity = await ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId }, ...SYS }); |
| 232 | + expect(identity.length, `${userId}: the verification mail persisted, in the identity-mail log`).toBeGreaterThan(0); |
| 233 | + expect(identity.every((m: Row) => !('organization_id' in m))).toBe(true); |
| 234 | + const tenantMail = await ql.find('sys_email', { where: { related_object: 'sys_user', related_id: userId }, ...SYS }); |
| 235 | + expect(tenantMail, `${userId}: no verification mail in the tenant mail log`).toEqual([]); |
| 236 | + } |
| 237 | + const identityColumns = (await sql('PRAGMA table_info("sys_identity_email")')).map((c) => c.name); |
| 238 | + expect(identityColumns.length, 'sys_identity_email is in the store').toBeGreaterThan(0); |
| 239 | + expect(identityColumns).not.toContain('organization_id'); |
| 240 | + }); |
| 241 | + }); |
| 242 | +} |
0 commit comments