|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0093 — membership lifecycle, end-to-end against a real stack. |
| 5 | + * |
| 6 | + * The original bug (#2882): user-creation paths outside better-auth's org |
| 7 | + * flows (`/admin/create-user`, sign-up) never wrote a `sys_member` row, so in |
| 8 | + * single-org mode those users didn't belong to the Default Organization. |
| 9 | + * ADR-0093 D2 gives the invariant ONE owner — a reconciler composed into |
| 10 | + * better-auth's `user.create.after` hook. |
| 11 | + * |
| 12 | + * Harness note: `bootStack` deliberately disables the default-org bootstrap |
| 13 | + * (`autoDefaultOrganization: false` — the ADR-0057 "single-tenant, no org row" |
| 14 | + * posture) and does not enable the better-auth admin plugin. So these tests |
| 15 | + * mint the single-org Default Organization themselves (system context, exactly |
| 16 | + * what the bootstrap would do) and drive the invariant through the REAL |
| 17 | + * better-auth sign-up pipeline — the path with no endpoint-side bind, where a |
| 18 | + * membership can only come from the `user.create.after` reconciler. The |
| 19 | + * endpoint-side create-user bind has its own unit coverage |
| 20 | + * (plugin-auth/src/admin-user-endpoints.test.ts). |
| 21 | + */ |
| 22 | + |
| 23 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 24 | +import showcaseStack from '@objectstack/example-showcase'; |
| 25 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 26 | +import { backfillMemberships, reconcileMembership } from '@objectstack/plugin-auth'; |
| 27 | + |
| 28 | +const SYSTEM_CTX = { isSystem: true }; |
| 29 | + |
| 30 | +async function findRows(ql: any, object: string, where: any, limit = 50): Promise<any[]> { |
| 31 | + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); |
| 32 | + return Array.isArray(rows) ? rows : (rows?.records ?? []); |
| 33 | +} |
| 34 | + |
| 35 | +describe('ADR-0093: membership lifecycle (single-org, real stack)', () => { |
| 36 | + let stack: VerifyStack; |
| 37 | + let ql: any; |
| 38 | + let defaultOrgId: string; |
| 39 | + |
| 40 | + beforeAll(async () => { |
| 41 | + stack = await bootStack(showcaseStack, {}); // single-org (no OS_MULTI_ORG_ENABLED) |
| 42 | + await stack.signIn(); |
| 43 | + ql = await stack.kernel.getServiceAsync<any>('objectql'); |
| 44 | + // Mint the Default Organization the single-org bootstrap would create |
| 45 | + // (the harness disables that bootstrap — see header). System context is |
| 46 | + // the legitimate writer for better-auth-managed tables (ADR-0092). |
| 47 | + const org = await ql.insert( |
| 48 | + 'sys_organization', |
| 49 | + { name: 'Default Organization', slug: 'default' }, |
| 50 | + { context: SYSTEM_CTX }, |
| 51 | + ); |
| 52 | + defaultOrgId = String(org.id); |
| 53 | + }, 120_000); |
| 54 | + |
| 55 | + afterAll(async () => { await stack?.stop?.(); }); |
| 56 | + |
| 57 | + it('sign-up produces a membership through the user.create.after reconciler alone (D2, e2e)', async () => { |
| 58 | + // No endpoint bind exists on the sign-up path — a membership here can ONLY |
| 59 | + // come from the reconciler hook consulting tenancy.defaultOrgId(). |
| 60 | + const token = await stack.signUp('reconciled.member@example.com', 'SignUp!Pass123', 'Reconciled Member'); |
| 61 | + expect(token).toBeTruthy(); |
| 62 | + const users = await findRows(ql, 'sys_user', { email: 'reconciled.member@example.com' }, 1); |
| 63 | + expect(users.length).toBe(1); |
| 64 | + const userId: string = users[0].id; |
| 65 | + |
| 66 | + // better-auth defers user.create.after past the signup transaction — |
| 67 | + // poll briefly before asserting. |
| 68 | + let members: any[] = []; |
| 69 | + for (let i = 0; i < 40 && members.length === 0; i++) { |
| 70 | + members = await findRows(ql, 'sys_member', { user_id: userId }, 5); |
| 71 | + if (members.length === 0) await new Promise((r) => setTimeout(r, 250)); |
| 72 | + } |
| 73 | + expect(members.length).toBe(1); |
| 74 | + expect(members[0].organization_id).toBe(defaultOrgId); |
| 75 | + expect(members[0].role).toBe('member'); |
| 76 | + }, 30_000); |
| 77 | + |
| 78 | + it('backfill binds a pre-existing member-less user and is idempotent (D6)', async () => { |
| 79 | + // A user created OUTSIDE the better-auth pipeline (system-context insert — |
| 80 | + // e.g. rows that predate the reconciler). |
| 81 | + const orphan = await ql.insert( |
| 82 | + 'sys_user', |
| 83 | + { name: 'Orphan User', email: 'orphan.user@example.com' }, |
| 84 | + { context: SYSTEM_CTX }, |
| 85 | + ); |
| 86 | + expect(orphan?.id).toBeTruthy(); |
| 87 | + expect((await findRows(ql, 'sys_member', { user_id: orphan.id })).length).toBe(0); |
| 88 | + |
| 89 | + const resolveTargetOrg = async () => defaultOrgId; |
| 90 | + const first = await backfillMemberships(ql, { policy: 'auto', resolveTargetOrg }); |
| 91 | + expect(first.bound).toBeGreaterThanOrEqual(1); // orphan (+ any other member-less user, e.g. the dev admin) |
| 92 | + const members = await findRows(ql, 'sys_member', { user_id: orphan.id }); |
| 93 | + expect(members.length).toBe(1); |
| 94 | + expect(members[0].organization_id).toBe(defaultOrgId); |
| 95 | + expect(members[0].role).toBe('member'); |
| 96 | + |
| 97 | + // Idempotent: a second run binds nothing new. |
| 98 | + const second = await backfillMemberships(ql, { policy: 'auto', resolveTargetOrg }); |
| 99 | + expect(second.bound).toBe(0); |
| 100 | + expect((await findRows(ql, 'sys_member', { user_id: orphan.id })).length).toBe(1); |
| 101 | + }, 30_000); |
| 102 | + |
| 103 | + it('invite-only policy never auto-binds, on the real engine (D1)', async () => { |
| 104 | + const loner = await ql.insert( |
| 105 | + 'sys_user', |
| 106 | + { name: 'Invite Only', email: 'invite.only@example.com' }, |
| 107 | + { context: SYSTEM_CTX }, |
| 108 | + ); |
| 109 | + |
| 110 | + const res = await reconcileMembership(ql, loner.id, { |
| 111 | + policy: 'invite-only', |
| 112 | + resolveTargetOrg: async () => defaultOrgId, |
| 113 | + }); |
| 114 | + expect(res.outcome).toBe('policy-skip'); |
| 115 | + expect((await findRows(ql, 'sys_member', { user_id: loner.id })).length).toBe(0); |
| 116 | + |
| 117 | + // Backfill refuses under invite-only too. |
| 118 | + const bf = await backfillMemberships(ql, { |
| 119 | + policy: 'invite-only', |
| 120 | + resolveTargetOrg: async () => defaultOrgId, |
| 121 | + }); |
| 122 | + expect(bf.reason).toBe('policy'); |
| 123 | + expect((await findRows(ql, 'sys_member', { user_id: loner.id })).length).toBe(0); |
| 124 | + }, 30_000); |
| 125 | + |
| 126 | + it('reconciler yields to an existing membership instead of double-binding (D2 yield rule)', async () => { |
| 127 | + // Simulate a host hook having bound the user to some OTHER org first — |
| 128 | + // the reconciler must respect it and never add a second membership. |
| 129 | + const hosted = await ql.insert( |
| 130 | + 'sys_user', |
| 131 | + { name: 'Host Bound', email: 'host.bound@example.com' }, |
| 132 | + { context: SYSTEM_CTX }, |
| 133 | + ); |
| 134 | + const otherOrg = await ql.insert( |
| 135 | + 'sys_organization', |
| 136 | + { name: 'Host Org', slug: 'host-org' }, |
| 137 | + { context: SYSTEM_CTX }, |
| 138 | + ); |
| 139 | + await ql.insert( |
| 140 | + 'sys_member', |
| 141 | + { organization_id: String(otherOrg.id), user_id: hosted.id, role: 'owner' }, |
| 142 | + { context: SYSTEM_CTX }, |
| 143 | + ); |
| 144 | + |
| 145 | + const res = await reconcileMembership(ql, hosted.id, { |
| 146 | + policy: 'auto', |
| 147 | + resolveTargetOrg: async () => defaultOrgId, |
| 148 | + }); |
| 149 | + expect(res.outcome).toBe('yielded'); |
| 150 | + const members = await findRows(ql, 'sys_member', { user_id: hosted.id }); |
| 151 | + expect(members.length).toBe(1); |
| 152 | + expect(members[0].organization_id).toBe(String(otherOrg.id)); // host's bind won |
| 153 | + }, 30_000); |
| 154 | +}); |
0 commit comments