|
42 | 42 | * mints the organization and sets membership roles in system context — the |
43 | 43 | * shape `delegated-admin-invite.dogfood.test.ts` uses, and the only writer |
44 | 44 | * better-auth-managed tables accept (ADR-0092). |
| 45 | + * |
| 46 | + * ## The one affordance no grade reaches: "Add Member" |
| 47 | + * |
| 48 | + * `sys_member.add_member` targets a door gated on PLATFORM-admin standing |
| 49 | + * (ADR-0068), not on a grade, so an org owner is refused it too. Its `visible` |
| 50 | + * reads that standing, `current_user.isPlatformAdmin == true` (ADR-0068 D4, |
| 51 | + * the ADR-0095 D3 rung). That case binds `current_user` the way the CONSOLE |
| 52 | + * does — the whole scope handed to the engine as `extra`, one subject built |
| 53 | + * from the served session — and ⛔ not through `user:`, under which |
| 54 | + * `@objectstack/formula` re-derives `isPlatformAdmin` from `positions` and so |
| 55 | + * measures the name instead of the rung the session carries. The grade cases |
| 56 | + * above keep their `user:` binding: their predicates read `positions` only. |
| 57 | + * The platform admin is the seeded dev admin (the harness's signed-in |
| 58 | + * principal); a second org owner who is NOT one is minted for the case. |
45 | 59 | */ |
46 | 60 |
|
47 | 61 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
@@ -122,6 +136,8 @@ describe('org-admin affordances follow the membership grade (served metadata × |
122 | 136 | const rowOf = {} as Record<string, Record<string, unknown>>; |
123 | 137 | let plainMemberRowId: string; |
124 | 138 | let pendingInvitationId: string; |
| 139 | + /** An org owner who is NOT a platform admin — the principal "Add Member" must not be offered to. */ |
| 140 | + const standingOwner = {} as { token: string; userId: string; session: Record<string, unknown>; served: Map<string, ServedAction> }; |
125 | 141 |
|
126 | 142 | beforeAll(async () => { |
127 | 143 | stack = await bootStack(showcaseStack, {}); |
@@ -155,6 +171,16 @@ describe('org-admin affordances follow the membership grade (served metadata × |
155 | 171 | } |
156 | 172 | } |
157 | 173 |
|
| 174 | + // A second owner of the same org, signed up rather than seeded, so the |
| 175 | + // owner grade is measured apart from platform-admin standing. |
| 176 | + standingOwner.token = await stack.signUp('reach.owner@example.com', 'Reach!Pass123', 'Reach second owner'); |
| 177 | + { |
| 178 | + const [user] = await findRows(ql, 'sys_user', { email: 'reach.owner@example.com' }, 1); |
| 179 | + standingOwner.userId = String(user.id); |
| 180 | + const membership = await waitForMembership(ql, standingOwner.userId); |
| 181 | + await ql.update('sys_member', { id: membership.id, role: 'owner' }, { context: SYSTEM_CTX }); |
| 182 | + } |
| 183 | + |
158 | 184 | // Rows for the row actions to bind against: a pending invitation, a team, |
159 | 185 | // and a team membership — created through the doors, as the owner. |
160 | 186 | const invite = await stack.apiAs(tokens.owner, 'POST', '/auth/organization/invite-member', { |
@@ -192,6 +218,16 @@ describe('org-admin affordances follow the membership grade (served metadata × |
192 | 218 | servedFields[grade][object] = Object.keys(body.item?.fields ?? {}); |
193 | 219 | } |
194 | 220 | } |
| 221 | + |
| 222 | + { |
| 223 | + const session = await stack.apiAs(standingOwner.token, 'GET', '/auth/get-session'); |
| 224 | + expect(session.status).toBe(200); |
| 225 | + standingOwner.session = ((await session.json()) as { user: Record<string, unknown> }).user; |
| 226 | + const meta = await stack.apiAs(standingOwner.token, 'GET', '/meta/object/sys_member'); |
| 227 | + expect(meta.status, 'the second owner reads /meta/object/sys_member').toBe(200); |
| 228 | + const body = (await meta.json()) as { item?: { actions?: ServedAction[] } }; |
| 229 | + standingOwner.served = new Map((body.item?.actions ?? []).map((a) => [`sys_member.${a.name}`, a])); |
| 230 | + } |
195 | 231 | }, 240_000); |
196 | 232 |
|
197 | 233 | afterAll(async () => { |
@@ -324,4 +360,74 @@ describe('org-admin affordances follow the membership grade (served metadata × |
324 | 360 | memberId: plainMemberRowId, role: 'owner', organizationId: orgId, |
325 | 361 | }), 'YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER'); |
326 | 362 | }, 60_000); |
| 363 | + |
| 364 | + it('Add Member is offered to a platform admin alone — current_user bound as the console binds it — and the door agrees', async () => { |
| 365 | + const servedAddMember = served.owner.get('sys_member.add_member')?.visible; |
| 366 | + expect(servedAddMember, 'add_member is served with a predicate').toBeDefined(); |
| 367 | + |
| 368 | + /** |
| 369 | + * The console's evaluation, not this file's `user:` one: objectui hands |
| 370 | + * its whole scope to the engine as `extra`, ONE subject built from the |
| 371 | + * served session under `current_user` / `user` / `ctx.user` / `os.user`, |
| 372 | + * with `features` beside it — so `isPlatformAdmin` is the session's own |
| 373 | + * value, the rung the door's gate judges. |
| 374 | + */ |
| 375 | + const consoleOffers = (session: Record<string, unknown>, own: Map<string, ServedAction>): boolean => { |
| 376 | + const action = own.get('sys_member.add_member'); |
| 377 | + if (!action) throw new Error('sys_member.add_member is not served to this principal'); |
| 378 | + expect(action.visible, 'one predicate is served to every principal').toEqual(servedAddMember); |
| 379 | + const subject = { |
| 380 | + id: session.id, |
| 381 | + name: session.name, |
| 382 | + email: session.email, |
| 383 | + isPlatformAdmin: session.isPlatformAdmin, |
| 384 | + positions: session.positions, |
| 385 | + }; |
| 386 | + const result = celEngine.evaluate(action.visible as never, { |
| 387 | + extra: { current_user: subject, user: subject, ctx: { user: subject }, os: { user: subject }, features }, |
| 388 | + }); |
| 389 | + if (!result.ok) throw new Error(`sys_member.add_member faulted: ${JSON.stringify(result)}`); |
| 390 | + return result.value === true; |
| 391 | + }; |
| 392 | + |
| 393 | + // The standing each principal's served session carries — measured, not |
| 394 | + // assumed: the seeded admin holds the rung, and no org grade does. |
| 395 | + const principals: Array<[string, Record<string, unknown>, Map<string, ServedAction>, boolean]> = [ |
| 396 | + ['platform admin (the seeded admin)', sessionUser.owner, served.owner, true], |
| 397 | + ['org owner, not a platform admin', standingOwner.session, standingOwner.served, false], |
| 398 | + ['org admin', sessionUser.admin, served.admin, false], |
| 399 | + ['delegated admin', sessionUser.delegated_admin, served.delegated_admin, false], |
| 400 | + ['plain member', sessionUser.member, served.member, false], |
| 401 | + ]; |
| 402 | + for (const [who, session, , platformAdmin] of principals) { |
| 403 | + expect(session.isPlatformAdmin, `${who}: the session's standing`).toBe(platformAdmin); |
| 404 | + } |
| 405 | + // The second owner really is an owner on the session face, so its hidden |
| 406 | + // verdict is about standing and not about a missing grade. |
| 407 | + expect(standingOwner.session.positions as string[]).toContain('org_owner'); |
| 408 | + |
| 409 | + // Both halves at once, every principal named: who IS offered it, and so |
| 410 | + // who is not. |
| 411 | + const offeredTo = principals.filter(([, session, own]) => consoleOffers(session, own)).map(([who]) => who); |
| 412 | + expect(offeredTo).toEqual(['platform admin (the seeded admin)']); |
| 413 | + |
| 414 | + // The door's own verdicts on the same boot: the owner and the plain member |
| 415 | + // are refused before anything is written; the platform admin is admitted. |
| 416 | + const org2 = await ql.insert('sys_organization', { name: 'Reach Org Two', slug: 'reach-org-two' }, { context: SYSTEM_CTX }); |
| 417 | + const attach = { userId: standingOwner.userId, role: 'member', organizationId: String(org2.id) }; |
| 418 | + for (const [who, token] of [['org owner', standingOwner.token], ['plain member', tokens.member]] as const) { |
| 419 | + const res = await stack.apiAs(token, 'POST', '/auth/organization/add-member', attach); |
| 420 | + const body = (await res.clone().json()) as { success?: boolean; error?: { code?: string } }; |
| 421 | + expect(res.status, `${who}: ${JSON.stringify(body)}`).toBe(403); |
| 422 | + expect(body.error?.code, who).toBe('PERMISSION_DENIED'); |
| 423 | + } |
| 424 | + expect(await findRows(ql, 'sys_member', { user_id: standingOwner.userId, organization_id: String(org2.id) }, 5)).toEqual([]); |
| 425 | + |
| 426 | + const admitted = await stack.apiAs(tokens.owner, 'POST', '/auth/organization/add-member', attach); |
| 427 | + const admittedBody = (await admitted.clone().json()) as { success?: boolean }; |
| 428 | + expect(admitted.status, JSON.stringify(admittedBody)).toBe(200); |
| 429 | + expect(admittedBody.success).toBe(true); |
| 430 | + const attached = await findRows(ql, 'sys_member', { user_id: standingOwner.userId, organization_id: String(org2.id) }, 5); |
| 431 | + expect(attached.map((m) => m.role)).toEqual(['member']); |
| 432 | + }, 60_000); |
327 | 433 | }); |
0 commit comments