|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * An OAuth access token acts in the ONE organization its consent bound |
| 5 | + * (ADR-0090 D10; ruling record 6232069920, letter A). |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * `resolveExecutionContext` built a verified MCP token's session as |
| 10 | + * `{ user: { id } }` — no organization at all. So the shared resolver derived |
| 11 | + * no tenant, and the D10 delegator (the user the agent acts for, resolved by |
| 12 | + * `plugin-security` in the request's organization) held its GLOBAL grants and |
| 13 | + * nothing else. Every grant an administrator assigns is scoped to an |
| 14 | + * organization (ADR-0131), so an ordinary user's agent was refused every |
| 15 | + * object operation: `on-behalf-of principal may not 'find' … the delegator |
| 16 | + * lacks that grant (ADR-0090 D10 intersection)`. |
| 17 | + * |
| 18 | + * ## The rule now |
| 19 | + * |
| 20 | + * The verifier returns the organization the consent bound |
| 21 | + * (`IAuthService.verifyMcpAccessToken` → `organizationId`, read from the |
| 22 | + * token's `organization_id` claim). The synthetic session carries it as the |
| 23 | + * session's `activeOrganizationId`, so the session arm of |
| 24 | + * `resolveAuthzContext` vets it on EVERY request with the rule it applies to a |
| 25 | + * browser session (`vetOrganizationClaim`): kept while a current membership |
| 26 | + * backs it under a walled posture, dropped (no organization, global grants |
| 27 | + * only) once none does. ⛔ No union of the user's organizations, no widened |
| 28 | + * global grant, no third branch in core. |
| 29 | + * |
| 30 | + * ## The rig |
| 31 | + * |
| 32 | + * The REAL `resolveExecutionContext` → `resolveAuthzContext` (this package's |
| 33 | + * vitest alias resolves `@objectstack/core` to its source) over a table double, |
| 34 | + * and the resulting context handed to the REAL `SecurityPlugin` middleware — |
| 35 | + * the layer that answered the card's denial — over the same tables, under the |
| 36 | + * `isolated` posture (the enforcing one). Only the token verifier is faked: its |
| 37 | + * own half (consent → claim → `organizationId`) is pinned against a real |
| 38 | + * authorization server in `@objectstack/plugin-auth`. |
| 39 | + */ |
| 40 | + |
| 41 | +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; |
| 42 | +import type { PermissionSet } from '@objectstack/spec/security'; |
| 43 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 44 | +import { SecurityPlugin, RLS_DENY_FILTER } from '@objectstack/plugin-security'; |
| 45 | + |
| 46 | +import { resolveExecutionContext } from './resolve-execution-context.js'; |
| 47 | +import { bindCatalogFromTables } from './security-catalog.testkit.js'; |
| 48 | + |
| 49 | +const ALPHA = 'org_alpha'; |
| 50 | +const BETA = 'org_beta'; |
| 51 | +/** An organization no fixture user belongs to. */ |
| 52 | +const GAMMA = 'org_gamma'; |
| 53 | +const CLIENT = 'agent-client-1'; |
| 54 | +const LEAD = 'crm_lead'; |
| 55 | +const CONTACT = 'crm_contact'; |
| 56 | + |
| 57 | +/** One source for every set: the catalog rows core reads and the bodies the security plugin reads. */ |
| 58 | +const SETS: PermissionSet[] = [ |
| 59 | + { name: 'crm_sales_user', label: 'Sales user', objects: { [LEAD]: { allowRead: true, allowCreate: true } } }, |
| 60 | + { name: 'crm_contact_reader', label: 'Contact reader', objects: { [CONTACT]: { allowRead: true } } }, |
| 61 | + { name: 'crm_lead_global', label: 'Lead (global)', objects: { [LEAD]: { allowRead: true, allowCreate: true } } }, |
| 62 | + { name: 'admin_full_access', label: 'Admin', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } } }, |
| 63 | +] as PermissionSet[]; |
| 64 | + |
| 65 | +/** |
| 66 | + * - `u_mia` holds `sales_rep` in org_alpha (→ `crm_sales_user`) and |
| 67 | + * `crm_contact_reader` in org_beta; a member of both — the "another |
| 68 | + * organization" control. |
| 69 | + * - `u_pat` is an org_alpha member holding no grant. |
| 70 | + * - `u_admin` holds `admin_full_access` GLOBALLY (the platform-admin row). |
| 71 | + * - `u_ex` held `sales_rep` in org_alpha and was removed from it (no |
| 72 | + * org_alpha `sys_member` row; still a member of org_beta). They also hold |
| 73 | + * `crm_lead_global` globally, so what is left after the claim is dropped is |
| 74 | + * visible: a read the wall empties and a write it refuses. |
| 75 | + */ |
| 76 | +function tables(): Record<string, any[]> { |
| 77 | + return { |
| 78 | + sys_api_key: [], |
| 79 | + sys_user: ['u_mia', 'u_pat', 'u_admin', 'u_ex'].map((id) => ({ id, email: `${id}@example.com` })), |
| 80 | + sys_member: [ |
| 81 | + { user_id: 'u_mia', organization_id: ALPHA, role: 'member' }, |
| 82 | + { user_id: 'u_mia', organization_id: BETA, role: 'member' }, |
| 83 | + { user_id: 'u_pat', organization_id: ALPHA, role: 'member' }, |
| 84 | + { user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }, |
| 85 | + { user_id: 'u_ex', organization_id: BETA, role: 'member' }, |
| 86 | + ], |
| 87 | + sys_user_position: [ |
| 88 | + { user_id: 'u_mia', position: 'sales_rep', organization_id: ALPHA }, |
| 89 | + { user_id: 'u_ex', position: 'sales_rep', organization_id: ALPHA }, |
| 90 | + ], |
| 91 | + sys_user_permission_set: [ |
| 92 | + { user_id: 'u_mia', permission_set: 'crm_contact_reader', organization_id: BETA }, |
| 93 | + { user_id: 'u_admin', permission_set: 'admin_full_access', organization_id: null }, |
| 94 | + { user_id: 'u_ex', permission_set: 'crm_lead_global', organization_id: null }, |
| 95 | + ], |
| 96 | + sys_permission_set: SETS.map((s) => ({ id: `ps_${s.name}`, name: s.name, object_permissions: JSON.stringify(s.objects ?? {}) })), |
| 97 | + sys_position: [{ id: 'pos_sales_rep', name: 'sales_rep', organization_id: null }], |
| 98 | + sys_position_permission_set: [{ position_id: 'pos_sales_rep', permission_set_id: 'ps_crm_sales_user' }], |
| 99 | + }; |
| 100 | +} |
| 101 | + |
| 102 | +/** Equality, `$in` and a NULL column; every other shape refused loudly. */ |
| 103 | +function matchesWhere(row: any, where: any): boolean { |
| 104 | + return Object.entries(where ?? {}).every(([field, cond]) => { |
| 105 | + if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); |
| 106 | + if (cond !== null && typeof cond === 'object') { |
| 107 | + if (!Array.isArray((cond as any).$in)) throw new Error(`fixture where-matcher: unsupported operator on '${field}'`); |
| 108 | + return (cond as any).$in.includes(row[field]); |
| 109 | + } |
| 110 | + if (cond === null) return (row[field] ?? null) === null; |
| 111 | + return row[field] === cond; |
| 112 | + }); |
| 113 | +} |
| 114 | + |
| 115 | +function schemaOf(name: string) { |
| 116 | + return { |
| 117 | + name, |
| 118 | + sharingModel: 'public_read_write', |
| 119 | + fields: { id: { name: 'id' }, name: { name: 'name', type: 'text' }, organization_id: { name: 'organization_id', type: 'text' } }, |
| 120 | + }; |
| 121 | +} |
| 122 | + |
| 123 | +/** The verified results the fake verifier answers, by bearer. */ |
| 124 | +type Verified = { userId: string; scopes: string[]; clientId?: string; organizationId?: string }; |
| 125 | + |
| 126 | +function rig(posture: 'isolated' | 'group' | 'single' | undefined) { |
| 127 | + const t = tables(); |
| 128 | + let middleware: any; |
| 129 | + const ql: any = bindCatalogFromTables({ |
| 130 | + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, |
| 131 | + getSchema: (name: string) => (name.startsWith('sys_') ? undefined : schemaOf(name)), |
| 132 | + async find(object: string, q: any = {}) { |
| 133 | + const rows = (t[object] ?? []).filter((r) => matchesWhere(r, q?.where)); |
| 134 | + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; |
| 135 | + }, |
| 136 | + async findOne(object: string, q: any = {}) { |
| 137 | + return (t[object] ?? []).filter((r) => matchesWhere(r, q?.where))[0] ?? null; |
| 138 | + }, |
| 139 | + }, t); |
| 140 | + |
| 141 | + const tokens = new Map<string, Verified>(); |
| 142 | + const services: Record<string, any> = { |
| 143 | + manifest: { register: vi.fn() }, |
| 144 | + objectql: ql, |
| 145 | + metadata: { |
| 146 | + get: async (_type: string, name: string) => (name.startsWith('sys_') ? null : schemaOf(name)), |
| 147 | + list: async () => SETS, |
| 148 | + }, |
| 149 | + auth: { verifyMcpAccessToken: async (bearer: string) => tokens.get(bearer) ?? null }, |
| 150 | + ...(posture ? { tenancy: { posture } } : {}), |
| 151 | + }; |
| 152 | + |
| 153 | + const resolve = async (verified: Verified): Promise<ExecutionContext> => { |
| 154 | + const bearer = `h.${tokens.size}.s`; |
| 155 | + tokens.set(bearer, verified); |
| 156 | + return resolveExecutionContext({ |
| 157 | + acceptOAuthAccessToken: true, |
| 158 | + getService: async (name: string) => services[name], |
| 159 | + getQl: async () => ql, |
| 160 | + request: { headers: { authorization: `Bearer ${bearer}` } }, |
| 161 | + }); |
| 162 | + }; |
| 163 | + |
| 164 | + /** The same user, signed in through a browser session naming `activeOrganizationId` — the REST door's shape. */ |
| 165 | + const resolveSession = async (userId: string, activeOrganizationId: string) => |
| 166 | + resolveExecutionContext({ |
| 167 | + getService: async (name: string) => (name === 'auth' |
| 168 | + ? { api: { getSession: async () => ({ user: { id: userId }, session: { id: `ses_${userId}`, userId, activeOrganizationId } }) } } |
| 169 | + : services[name]), |
| 170 | + getQl: async () => ql, |
| 171 | + request: { headers: {} }, |
| 172 | + }); |
| 173 | + |
| 174 | + const boot = async () => { |
| 175 | + const plugin = new SecurityPlugin({ fallbackPermissionSet: null }); |
| 176 | + const ctx: any = { |
| 177 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 178 | + registerService: vi.fn(), |
| 179 | + getService: (name: string) => { |
| 180 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 181 | + return services[name]; |
| 182 | + }, |
| 183 | + }; |
| 184 | + await plugin.init(ctx); |
| 185 | + await plugin.start(ctx); |
| 186 | + }; |
| 187 | + |
| 188 | + /** Run one operation through the security middleware; the engine body stands in for `next()`. */ |
| 189 | + const run = async (context: ExecutionContext, operation: 'find' | 'insert', object: string) => { |
| 190 | + const opCtx: any = operation === 'find' |
| 191 | + ? { object, operation, ast: { where: undefined }, context } |
| 192 | + : { object, operation, data: { name: 'written' }, context }; |
| 193 | + await middleware(opCtx, async () => { |
| 194 | + const seam = opCtx.postHookWriteImageCheck; |
| 195 | + if (seam) { |
| 196 | + seam.honoured = true; |
| 197 | + await seam.evaluate([opCtx.data]); |
| 198 | + } |
| 199 | + if (operation === 'find') opCtx.result = []; |
| 200 | + }); |
| 201 | + return opCtx; |
| 202 | + }; |
| 203 | + |
| 204 | + return { resolve, resolveSession, boot, run }; |
| 205 | +} |
| 206 | + |
| 207 | +const READ_TOKEN = (userId: string, organizationId?: string): Verified => ({ |
| 208 | + userId, |
| 209 | + scopes: ['data:read', 'data:write'], |
| 210 | + clientId: CLIENT, |
| 211 | + ...(organizationId ? { organizationId } : {}), |
| 212 | +}); |
| 213 | + |
| 214 | +/** |
| 215 | + * The composed read filter's conjuncts. D10 ANDs the agent's Layer 0 with the |
| 216 | + * DELEGATOR's, so a delegated read under a wall carries one per principal. |
| 217 | + */ |
| 218 | +const conjuncts = (where: any): unknown[] => (where && Array.isArray(where.$and) ? where.$and : [where]); |
| 219 | + |
| 220 | +/** The card's message: the D10 intersection refusing the delegator's missing grant. */ |
| 221 | +const D10_DENIAL = /on-behalf-of principal may not '(find|insert)' .* the delegator lacks that grant \(ADR-0090 D10 intersection\)/; |
| 222 | + |
| 223 | +let warn: ReturnType<typeof vi.spyOn>; |
| 224 | +beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); |
| 225 | +afterEach(() => { warn.mockRestore(); }); |
| 226 | + |
| 227 | +const droppedClaimLines = () => warn.mock.calls |
| 228 | + .map((c) => String(c[0])) |
| 229 | + .filter((line) => line.includes('organization claim dropped (organization_membership_ended)')); |
| 230 | + |
| 231 | +describe('an OAuth token acts in the organization its consent bound — isolated posture, the real D10 middleware', () => { |
| 232 | + const r = rig('isolated'); |
| 233 | + beforeAll(async () => { await r.boot(); }); |
| 234 | + |
| 235 | + it('POSITIVE: the token bound to org_alpha reads and creates with the organization-scoped grant the user holds there', async () => { |
| 236 | + const ctx = await r.resolve(READ_TOKEN('u_mia', ALPHA)); |
| 237 | + expect(ctx.tenantId).toBe(ALPHA); |
| 238 | + expect(ctx.principalKind).toBe('agent'); |
| 239 | + expect(ctx.onBehalfOf).toEqual({ userId: 'u_mia', principalKind: 'human' }); |
| 240 | + |
| 241 | + const read = await r.run(ctx, 'find', LEAD); |
| 242 | + // Admitted, and walled to the bound organization alone. |
| 243 | + expect(conjuncts(read.ast.where)).toEqual([{ organization_id: ALPHA }, { organization_id: ALPHA }]); |
| 244 | + await expect(r.run(ctx, 'insert', LEAD)).resolves.toBeDefined(); |
| 245 | + expect(droppedClaimLines()).toEqual([]); |
| 246 | + }); |
| 247 | + |
| 248 | + it('PARITY: the same user over a browser session in org_alpha resolves the same organization', async () => { |
| 249 | + const viaToken = await r.resolve(READ_TOKEN('u_mia', ALPHA)); |
| 250 | + const viaSession = await r.resolveSession('u_mia', ALPHA); |
| 251 | + expect(viaToken.tenantId).toBe(viaSession.tenantId); |
| 252 | + expect(viaToken.accessible_org_ids).toEqual(viaSession.accessible_org_ids); |
| 253 | + expect(viaSession.permissions).toContain('crm_sales_user'); |
| 254 | + }); |
| 255 | + |
| 256 | + it('CONTROL — another organization stays invisible: bound to org_alpha, the org_beta grant does not count and the wall names org_alpha alone', async () => { |
| 257 | + const ctx = await r.resolve(READ_TOKEN('u_mia', ALPHA)); |
| 258 | + await expect(r.run(ctx, 'find', CONTACT)).rejects.toThrow(D10_DENIAL); |
| 259 | + |
| 260 | + // Bound to org_beta instead: the reverse, never the union. |
| 261 | + const beta = await r.resolve(READ_TOKEN('u_mia', BETA)); |
| 262 | + expect(beta.tenantId).toBe(BETA); |
| 263 | + const contacts = await r.run(beta, 'find', CONTACT); |
| 264 | + expect(conjuncts(contacts.ast.where)).toEqual([{ organization_id: BETA }, { organization_id: BETA }]); |
| 265 | + await expect(r.run(beta, 'find', LEAD)).rejects.toThrow(D10_DENIAL); |
| 266 | + }); |
| 267 | + |
| 268 | + it('CONTROL — no grant still refuses: an org_alpha member holding nothing is refused in org_alpha', async () => { |
| 269 | + const ctx = await r.resolve(READ_TOKEN('u_pat', ALPHA)); |
| 270 | + expect(ctx.tenantId).toBe(ALPHA); |
| 271 | + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); |
| 272 | + }); |
| 273 | + |
| 274 | + it('CONTROL — a revoked membership: the claim is dropped, so only global grants apply — the read comes back empty and the tenant write is refused loudly', async () => { |
| 275 | + const ctx = await r.resolve(READ_TOKEN('u_ex', ALPHA)); |
| 276 | + expect(ctx.tenantId).toBeUndefined(); |
| 277 | + expect(droppedClaimLines()).toHaveLength(1); |
| 278 | + expect(droppedClaimLines()[0]).toContain(`principal=u_ex organization=${ALPHA}`); |
| 279 | + |
| 280 | + // The global grant admits the read; the wall has no organization to read in. |
| 281 | + const read = await r.run(ctx, 'find', LEAD); |
| 282 | + expect(conjuncts(read.ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]); |
| 283 | + await expect(r.run(ctx, 'insert', LEAD)).rejects.toThrow(/has no active organization/); |
| 284 | + }); |
| 285 | + |
| 286 | + it('CONTROL — a platform admin\'s global grant is unchanged: it admits the delegated read bound or unbound; only the organization the wall reads in moves', async () => { |
| 287 | + const bound = await r.resolve(READ_TOKEN('u_admin', ALPHA)); |
| 288 | + expect(bound.tenantId).toBe(ALPHA); |
| 289 | + expect(bound.posture).toBe('PLATFORM_ADMIN'); |
| 290 | + expect(conjuncts((await r.run(bound, 'find', LEAD)).ast.where)).toEqual([{ organization_id: ALPHA }, { organization_id: ALPHA }]); |
| 291 | + |
| 292 | + // Unbound is what every token resolved before this change: still admitted |
| 293 | + // by the global grant, and — a business object under `isolated` — walled |
| 294 | + // to nothing with no organization to read in (ADR-0095 W2), as before. |
| 295 | + const unbound = await r.resolve(READ_TOKEN('u_admin')); |
| 296 | + expect(unbound.tenantId).toBeUndefined(); |
| 297 | + expect(unbound.posture).toBe('PLATFORM_ADMIN'); |
| 298 | + expect(conjuncts((await r.run(unbound, 'find', LEAD)).ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]); |
| 299 | + }); |
| 300 | + |
| 301 | + it('CONTROL — a token minted before consents bound an organization (no claim) keeps today\'s behaviour: global grants only', async () => { |
| 302 | + const ctx = await r.resolve(READ_TOKEN('u_mia')); |
| 303 | + expect(ctx.tenantId).toBeUndefined(); |
| 304 | + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); |
| 305 | + expect(droppedClaimLines()).toEqual([]); |
| 306 | + }); |
| 307 | + |
| 308 | + it('CONTROL — an organization the user does not belong to (a resource row\'s custom claim, say) is dropped, never honoured', async () => { |
| 309 | + const ctx = await r.resolve(READ_TOKEN('u_mia', GAMMA)); |
| 310 | + expect(ctx.tenantId).toBeUndefined(); |
| 311 | + expect(droppedClaimLines()).toHaveLength(1); |
| 312 | + expect(droppedClaimLines()[0]).toContain(`principal=u_mia organization=${GAMMA}`); |
| 313 | + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); |
| 314 | + }); |
| 315 | +}); |
| 316 | + |
| 317 | +/** |
| 318 | + * The open-core default composition the card measured (`single`: no wall). |
| 319 | + * The token's organization still decides which organization-scoped grants |
| 320 | + * count, and — no wall, no membership read — it stands as claimed, exactly |
| 321 | + * as a browser session's `activeOrganizationId` does there. |
| 322 | + */ |
| 323 | +describe('the same token under the single posture (no wall) — the real D10 middleware', () => { |
| 324 | + const r = rig('single'); |
| 325 | + beforeAll(async () => { await r.boot(); }); |
| 326 | + |
| 327 | + it('POSITIVE: the token bound to org_alpha reads with the organization-scoped grant (the card\'s measured case)', async () => { |
| 328 | + const ctx = await r.resolve(READ_TOKEN('u_mia', ALPHA)); |
| 329 | + expect(ctx.tenantId).toBe(ALPHA); |
| 330 | + expect((await r.run(ctx, 'find', LEAD)).ast.where).toBeUndefined(); |
| 331 | + await expect(r.run(ctx, 'insert', LEAD)).resolves.toBeDefined(); |
| 332 | + }); |
| 333 | + |
| 334 | + it('CONTROL — no claim: the card\'s denial, unchanged', async () => { |
| 335 | + const ctx = await r.resolve(READ_TOKEN('u_mia')); |
| 336 | + expect(ctx.tenantId).toBeUndefined(); |
| 337 | + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); |
| 338 | + }); |
| 339 | + |
| 340 | + it('BOUNDARY — a former member\'s token keeps the bound organization here, as their session would: no wall reads no membership', async () => { |
| 341 | + const viaToken = await r.resolve(READ_TOKEN('u_ex', ALPHA)); |
| 342 | + const viaSession = await r.resolveSession('u_ex', ALPHA); |
| 343 | + expect(viaToken.tenantId).toBe(ALPHA); |
| 344 | + expect(viaSession.tenantId).toBe(ALPHA); |
| 345 | + expect(droppedClaimLines()).toEqual([]); |
| 346 | + await expect(r.run(viaToken, 'find', LEAD)).resolves.toBeDefined(); |
| 347 | + }); |
| 348 | +}); |
| 349 | + |
| 350 | +/** |
| 351 | + * The vetting is the session arm's, so the token's organization answers |
| 352 | + * exactly what a browser session's does in every posture: vetted against the |
| 353 | + * user's memberships where a wall is enforced (`isolated`, `group`), kept as |
| 354 | + * claimed where none is (`single`, or no `tenancy` service) — the same claim |
| 355 | + * a REST session's `activeOrganizationId` keeps there. |
| 356 | + */ |
| 357 | +describe('the token\'s organization follows the session arm in every posture', () => { |
| 358 | + const CASES: Array<['isolated' | 'group' | 'single' | undefined, { member: string | undefined; stranger: string | undefined }]> = [ |
| 359 | + ['isolated', { member: ALPHA, stranger: undefined }], |
| 360 | + ['group', { member: ALPHA, stranger: undefined }], |
| 361 | + ['single', { member: ALPHA, stranger: GAMMA }], |
| 362 | + [undefined, { member: ALPHA, stranger: GAMMA }], |
| 363 | + ]; |
| 364 | + for (const [posture, expected] of CASES) { |
| 365 | + it(`${posture ?? 'no tenancy service'}: a member organization ${expected.member ? 'is kept' : 'is dropped'}; a non-member one ${expected.stranger ? 'is kept' : 'is dropped'} — as for a session`, async () => { |
| 366 | + const r = rig(posture); |
| 367 | + for (const [org, want] of [[ALPHA, expected.member], [GAMMA, expected.stranger]] as const) { |
| 368 | + const viaToken = await r.resolve(READ_TOKEN('u_mia', org)); |
| 369 | + const viaSession = await r.resolveSession('u_mia', org); |
| 370 | + expect(viaToken.tenantId, `token bound to ${org}`).toBe(want); |
| 371 | + expect(viaSession.tenantId, `session naming ${org}`).toBe(want); |
| 372 | + } |
| 373 | + }); |
| 374 | + } |
| 375 | +}); |
0 commit comments