Skip to content

Commit d026e76

Browse files
committed
test(dogfood): a walled leg of the sign-up organization census (wip)
Claude-Session: https://claude.ai/code/session_01UQVH1PSsJbZT4VvCZqEiRu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 123ff6f commit d026e76

1 file changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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

Comments
 (0)