|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #15747 — the current-user faces assemble their `ExecutionContext` through the |
| 4 | +// SHARED assembler, not a hand-rolled literal. |
| 5 | +// |
| 6 | +// `makeExecutionContextResolver` built the envelope for all three current-user |
| 7 | +// routes as an object literal cast `as any`. `assembleExecutionContext` |
| 8 | +// (@objectstack/core) exists precisely to make that shape unrepresentable — it |
| 9 | +// CLOSES the field set with a type, so a transport entry point cannot silently |
| 10 | +// omit a field — and the resolver sat beside it omitting six of them: |
| 11 | +// |
| 12 | +// principalKind · onBehalfOf · audience · accessToken · authGate · oauthScopes |
| 13 | +// |
| 14 | +// (`locale` / `timezone` / `currency` were the seventh through ninth. #15387 |
| 15 | +// repaired those AT THE ENDPOINT — `/auth/me/localization` reads the |
| 16 | +// localization cascade itself and no longer reads them off this envelope at |
| 17 | +// all — so they are withheld here on the record rather than resolved twice.) |
| 18 | +// |
| 19 | +// ## What these cases measure, and why each is not vacuous |
| 20 | +// |
| 21 | +// The envelope is handed to exactly ONE consumer on these faces: |
| 22 | +// `ISecurityService.resolvePermissionSetsForContext`. So the tests capture the |
| 23 | +// context that ARRIVES there — the same instrumentation #6071 used on the REST |
| 24 | +// face (`packages/rest/src/rest-exec-ctx-principal-kind.test.ts`) — through the |
| 25 | +// REAL registered routes, driven with `app.request()`. Nothing here mocks the |
| 26 | +// resolver. |
| 27 | +// |
| 28 | +// ⚠️ An assertion that `principalKind` is merely PRESENT would be close to |
| 29 | +// vacuous: its only reachable reader on these faces is the security plugin's |
| 30 | +// `const isAgent = context?.principalKind === 'agent'`, and `undefined` and |
| 31 | +// `'human'` are indistinguishable there. So the cases assert the two things |
| 32 | +// that genuinely differ: |
| 33 | +// |
| 34 | +// 1. the envelope's KEY SET equals the shared assembler's output exactly — |
| 35 | +// which fails both when a field is omitted and when one is over-filled; |
| 36 | +// 2. `'agent'` is UNREACHABLE on this face — the load-bearing leg of the |
| 37 | +// card's LATENT grade, pinned so that acquiring an OAuth door here turns |
| 38 | +// it red instead of silently promoting the hazard to a live defect. |
| 39 | + |
| 40 | +import { describe, it, expect, vi } from 'vitest'; |
| 41 | +import { Hono } from 'hono'; |
| 42 | +import { assembleExecutionContext, resolveUserAuthzGrants, ENTRY_EXECUTION_CONTEXT_FIELDS } from '@objectstack/core'; |
| 43 | +import { registerCurrentUserEndpoints } from './current-user-endpoints'; |
| 44 | + |
| 45 | +const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; |
| 46 | +const ME_LOCALIZATION = '/api/v1/auth/me/localization'; |
| 47 | +const ME_APPS = '/api/v1/me/apps'; |
| 48 | + |
| 49 | +const USER = 'usr_member'; |
| 50 | +const EMAIL = 'member@example.com'; |
| 51 | +const ACTIVE_ORG = 'org_active'; |
| 52 | +const GRANTED = 'showcase_ops'; |
| 53 | + |
| 54 | +type Row = Record<string, any>; |
| 55 | + |
| 56 | +/** The sets the `security` service hands back — whole, as the contract publishes them. */ |
| 57 | +const RESOLVED = [ |
| 58 | + { |
| 59 | + name: GRANTED, |
| 60 | + label: 'Showcase Ops', |
| 61 | + systemPermissions: ['showcase.export_data'], |
| 62 | + tabPermissions: { exports: 'visible' }, |
| 63 | + objects: { showcase_order: { allowRead: true, allowEdit: true } }, |
| 64 | + fields: { 'showcase_order.total': { readable: true, editable: true } }, |
| 65 | + }, |
| 66 | +]; |
| 67 | + |
| 68 | +const APPS = [ |
| 69 | + { name: 'exports', requiredPermissions: ['showcase.export_data'] }, |
| 70 | + { name: 'open', requiredPermissions: [] }, |
| 71 | +]; |
| 72 | + |
| 73 | +/** `where` matcher: scalar equality plus the `$in` form the resolver sends. */ |
| 74 | +function matches(row: Row, where: Row | undefined): boolean { |
| 75 | + return Object.entries(where ?? {}).every(([key, cond]) => { |
| 76 | + // REFUSE an unsupported combinator rather than reading it as a field |
| 77 | + // name — a silent `false` would look exactly like a row that did not |
| 78 | + // match, and a case would pass for the wrong reason. |
| 79 | + if (key.startsWith('$')) throw new Error(`fake driver: unsupported operator ${key}`); |
| 80 | + const value = row[key] ?? null; |
| 81 | + if (cond && typeof cond === 'object' && Array.isArray((cond as any).$in)) { |
| 82 | + return (cond as any).$in.includes(value); |
| 83 | + } |
| 84 | + return value === (cond ?? null); |
| 85 | + }); |
| 86 | +} |
| 87 | + |
| 88 | +const TABLES: Record<string, Row[]> = { |
| 89 | + sys_user: [{ id: USER, email: EMAIL }], |
| 90 | + sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }], |
| 91 | + sys_user_position: [], |
| 92 | + sys_user_permission_set: [ |
| 93 | + { id: 'ups1', user_id: USER, permission_set_id: 'ps_ops', organization_id: ACTIVE_ORG }, |
| 94 | + ], |
| 95 | + sys_position: [], |
| 96 | + sys_position_permission_set: [], |
| 97 | + sys_permission_set: [{ id: 'ps_ops', name: GRANTED }], |
| 98 | +}; |
| 99 | + |
| 100 | +const makeQl = () => ({ |
| 101 | + find: async (object: string, opts: any) => { |
| 102 | + const rows = (TABLES[object] ?? []).filter((r) => matches(r, opts?.where)); |
| 103 | + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; |
| 104 | + }, |
| 105 | + registry: { getAllApps: () => APPS, getAllObjects: () => [] }, |
| 106 | + getSchema: () => undefined, |
| 107 | +}); |
| 108 | + |
| 109 | +/** |
| 110 | + * Mount the REAL routes on a REAL Hono app. |
| 111 | + * |
| 112 | + * @param authHeaders headers the request presents (the OAuth-door case sends a |
| 113 | + * JWT-shaped bearer here). |
| 114 | + */ |
| 115 | +function mount() { |
| 116 | + /** Every context that ARRIVED at the one consumer, in call order. */ |
| 117 | + const seen: any[] = []; |
| 118 | + /** |
| 119 | + * The OAuth verifier the `/mcp` door calls |
| 120 | + * (`resolve-execution-context.ts` → `authService.verifyMcpAccessToken`). |
| 121 | + * Present on the service so that "this face never calls it" is a MEASURED |
| 122 | + * absence rather than an absent method that could never have been called. |
| 123 | + */ |
| 124 | + const verifyMcpAccessToken = vi.fn(async () => ({ |
| 125 | + userId: USER, |
| 126 | + scopes: ['data:read', 'actions:execute'], |
| 127 | + clientId: 'cli_agent_1', |
| 128 | + })); |
| 129 | + |
| 130 | + const services: Record<string, unknown> = { |
| 131 | + auth: { |
| 132 | + verifyMcpAccessToken, |
| 133 | + api: { |
| 134 | + getSession: async () => ({ |
| 135 | + user: { id: USER, email: EMAIL }, |
| 136 | + session: { activeOrganizationId: ACTIVE_ORG }, |
| 137 | + }), |
| 138 | + }, |
| 139 | + }, |
| 140 | + objectql: makeQl(), |
| 141 | + metadata: { list: async () => [] as unknown[] }, |
| 142 | + security: { |
| 143 | + resolvePermissionSetsForContext: async (context: any) => { |
| 144 | + seen.push(context); |
| 145 | + return RESOLVED; |
| 146 | + }, |
| 147 | + }, |
| 148 | + }; |
| 149 | + |
| 150 | + const app = new Hono(); |
| 151 | + registerCurrentUserEndpoints({ |
| 152 | + rawApp: app, |
| 153 | + ctx: { |
| 154 | + logger: { debug() {}, warn() {} }, |
| 155 | + getService: <T,>(name: string): T => { |
| 156 | + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); |
| 157 | + return services[name] as T; |
| 158 | + }, |
| 159 | + }, |
| 160 | + }); |
| 161 | + return { app, seen, verifyMcpAccessToken }; |
| 162 | +} |
| 163 | + |
| 164 | +const get = (app: any, path: string, headers?: Record<string, string>) => |
| 165 | + app.request(`http://localhost${path}`, headers ? { headers } : undefined); |
| 166 | + |
| 167 | +/** |
| 168 | + * The envelope the SHARED assembler produces for this fixture's principal — |
| 169 | + * the reference these faces must agree with. Built from the same |
| 170 | + * `ResolvedAuthzContext` the resolver resolves, with every per-face divergence |
| 171 | + * passed explicitly, exactly as the resolver passes it. |
| 172 | + */ |
| 173 | +const reference = async () => |
| 174 | + assembleExecutionContext({ |
| 175 | + // The SAME grant resolution the faces run — `resolveUserAuthzGrants` is |
| 176 | + // shared INPUT, not the subject. What is under test is the ASSEMBLY |
| 177 | + // step after it, which is the step that was hand-rolled. |
| 178 | + authz: { |
| 179 | + ...(await resolveUserAuthzGrants(makeQl(), USER, { tenantId: ACTIVE_ORG })), |
| 180 | + userId: USER, |
| 181 | + tenantId: ACTIVE_ORG, |
| 182 | + }, |
| 183 | + oauth: undefined, |
| 184 | + localization: undefined, |
| 185 | + requestLocale: undefined, |
| 186 | + accessToken: undefined, |
| 187 | + authGate: undefined, |
| 188 | + })!; |
| 189 | + |
| 190 | +describe('[#15747] the current-user faces assemble through the shared assembler', () => { |
| 191 | + it('/auth/me/permissions hands its consumer the assembler-shaped envelope', async () => { |
| 192 | + const { app, seen } = mount(); |
| 193 | + await get(app, ME_PERMISSIONS); |
| 194 | + |
| 195 | + expect(seen).toHaveLength(1); |
| 196 | + // THE measurement. Before the repair the literal emitted 11 keys and |
| 197 | + // `principalKind` was not among them; the assembler emits the closed |
| 198 | + // set minus the fields withheld on the record. |
| 199 | + expect(Object.keys(seen[0]).sort()).toEqual(Object.keys(await reference()).sort()); |
| 200 | + }); |
| 201 | + |
| 202 | + it('/me/apps hands its consumer the SAME envelope shape', async () => { |
| 203 | + const { app, seen } = mount(); |
| 204 | + await get(app, ME_APPS); |
| 205 | + |
| 206 | + expect(seen).toHaveLength(1); |
| 207 | + expect(Object.keys(seen[0]).sort()).toEqual(Object.keys(await reference()).sort()); |
| 208 | + }); |
| 209 | + |
| 210 | + it('every key the faces emit belongs to the closed entry set', async () => { |
| 211 | + const { app, seen } = mount(); |
| 212 | + await get(app, ME_PERMISSIONS); |
| 213 | + |
| 214 | + const closed = new Set<string>(ENTRY_EXECUTION_CONTEXT_FIELDS as readonly string[]); |
| 215 | + expect(Object.keys(seen[0]).filter((k) => !closed.has(k))).toEqual([]); |
| 216 | + }); |
| 217 | + |
| 218 | + it('the principal is a HUMAN, and the six omitted fields are decided rather than dropped', async () => { |
| 219 | + const { app, seen } = mount(); |
| 220 | + await get(app, ME_PERMISSIONS); |
| 221 | + const ctx = seen[0]; |
| 222 | + |
| 223 | + // The one omitted field with a live downstream reader |
| 224 | + // (`plugin-security`: `context?.principalKind === 'agent'`). |
| 225 | + expect(ctx.principalKind).toBe('human'); |
| 226 | + // The other five: WITHHELD on this face, and `emit()` drops an |
| 227 | + // `undefined` decision — so their absence is now the assembler's |
| 228 | + // recorded answer rather than a hand-rolled omission. Asserted |
| 229 | + // together with the key-set equality above, which is what makes this |
| 230 | + // pair a statement about the whole set rather than about six names. |
| 231 | + for (const field of ['onBehalfOf', 'audience', 'accessToken', 'authGate', 'oauthScopes']) { |
| 232 | + expect(ctx[field]).toBeUndefined(); |
| 233 | + } |
| 234 | + }); |
| 235 | + |
| 236 | + // ⭐ The card's LATENT grade rests on exactly this: `principalKind` is read |
| 237 | + // downstream ONLY to test for `'agent'`, and an agent principal requires an |
| 238 | + // OAuth access token naming an authorized client — which the shared |
| 239 | + // assembler produces from its `oauth` input, and which reaches it from the |
| 240 | + // `/mcp` dispatch door ALONE (`acceptOAuthAccessToken`). This face resolves |
| 241 | + // its principal from the better-auth SESSION and never opens that door. |
| 242 | + // |
| 243 | + // If this case ever goes red, the grade has flipped: an absent |
| 244 | + // `principalKind` would then be reachable as something other than 'human' |
| 245 | + // and the omission becomes a live, security-relevant defect. |
| 246 | + it('an OAuth-shaped bearer cannot produce an AGENT principal on this face', async () => { |
| 247 | + const { app, seen, verifyMcpAccessToken } = mount(); |
| 248 | + await get(app, ME_PERMISSIONS, { |
| 249 | + authorization: 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.e30.sig', |
| 250 | + }); |
| 251 | + |
| 252 | + // The face never consults the OAuth verifier — the method is on the |
| 253 | + // service and would have answered an agent principal had it been asked. |
| 254 | + expect(verifyMcpAccessToken).not.toHaveBeenCalled(); |
| 255 | + expect(seen[0].principalKind).toBe('human'); |
| 256 | + expect(seen[0].onBehalfOf).toBeUndefined(); |
| 257 | + expect(seen[0].oauthScopes).toBeUndefined(); |
| 258 | + }); |
| 259 | + |
| 260 | + // The blast radius #15387 declined to take on: converting the resolver |
| 261 | + // changes the envelope handed to /auth/me/permissions and /me/apps too. |
| 262 | + // These three cases are the before/after on each face's WIRE. |
| 263 | + it('/auth/me/permissions answers the same body', async () => { |
| 264 | + const { app } = mount(); |
| 265 | + expect(await (await get(app, ME_PERMISSIONS)).json()).toEqual({ |
| 266 | + authenticated: true, |
| 267 | + userId: USER, |
| 268 | + tenantId: ACTIVE_ORG, |
| 269 | + // The MEASURED before-state, not a guess: org membership folds |
| 270 | + // to `org_member` and the ADR-0090 D5 `everyone` anchor is |
| 271 | + // implicit. Recorded here so the after-run asserts identity with |
| 272 | + // what this face answered before the conversion. |
| 273 | + positions: ['org_member', 'everyone'], |
| 274 | + permissionSets: [GRANTED], |
| 275 | + objects: { showcase_order: { allowRead: true, allowEdit: true } }, |
| 276 | + fields: { 'showcase_order.total': { readable: true, editable: true } }, |
| 277 | + systemPermissions: ['showcase.export_data'], |
| 278 | + tabPermissions: { exports: 'visible' }, |
| 279 | + }); |
| 280 | + }); |
| 281 | + |
| 282 | + it('/me/apps answers the same body', async () => { |
| 283 | + const { app } = mount(); |
| 284 | + expect(await (await get(app, ME_APPS)).json()).toEqual({ |
| 285 | + apps: [{ name: 'exports', requiredPermissions: ['showcase.export_data'] }, { name: 'open', requiredPermissions: [] }], |
| 286 | + }); |
| 287 | + }); |
| 288 | + |
| 289 | + it('/auth/me/localization answers the same body', async () => { |
| 290 | + const { app } = mount(); |
| 291 | + // Reads the localization cascade itself since #15387 — it consults the |
| 292 | + // envelope for `userId` / `tenantId` and nothing else, so converting |
| 293 | + // the resolver must leave this wire untouched. |
| 294 | + expect(await (await get(app, ME_LOCALIZATION)).json()).toEqual({ |
| 295 | + authenticated: true, |
| 296 | + currency: null, |
| 297 | + locale: 'en-US', |
| 298 | + timezone: 'UTC', |
| 299 | + }); |
| 300 | + }); |
| 301 | + |
| 302 | + it('an unauthenticated request still reaches no consumer at all', async () => { |
| 303 | + // The fail-closed half: `assembleExecutionContext` answers `undefined` |
| 304 | + // for a principal-less authz context, and these faces answered |
| 305 | + // `{authenticated:false}` / `{apps:[]}` before it. Both must hold, or |
| 306 | + // the conversion would have turned a 'no session' answer into a guest |
| 307 | + // ENVELOPE reaching enforcement — the thing |
| 308 | + // `assembleExecutionContextOrGuest` is the named entry for, and which |
| 309 | + // this face deliberately does NOT adopt. |
| 310 | + const seen: any[] = []; |
| 311 | + const services: Record<string, unknown> = { |
| 312 | + auth: { api: { getSession: async () => undefined } }, |
| 313 | + objectql: makeQl(), |
| 314 | + metadata: { list: async () => [] as unknown[] }, |
| 315 | + security: { |
| 316 | + resolvePermissionSetsForContext: async (context: any) => { |
| 317 | + seen.push(context); |
| 318 | + return RESOLVED; |
| 319 | + }, |
| 320 | + }, |
| 321 | + }; |
| 322 | + const app = new Hono(); |
| 323 | + registerCurrentUserEndpoints({ |
| 324 | + rawApp: app, |
| 325 | + ctx: { |
| 326 | + logger: { debug() {}, warn() {} }, |
| 327 | + getService: <T,>(name: string): T => { |
| 328 | + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); |
| 329 | + return services[name] as T; |
| 330 | + }, |
| 331 | + }, |
| 332 | + }); |
| 333 | + |
| 334 | + expect(await (await get(app, ME_PERMISSIONS)).json()).toEqual({ authenticated: false }); |
| 335 | + expect(await (await get(app, ME_APPS)).json()).toEqual({ apps: [] }); |
| 336 | + expect(seen).toEqual([]); |
| 337 | + }); |
| 338 | +}); |
0 commit comments