// The app's ACCESS POSTURE, asserted from BOTH sides against the real resolver. // // Why this file exists rather than one guard assertion per handler test: a // per-procedure test hands `invoke` a subject it constructed itself, so it // passes with a scope literal no real caller could ever obtain. The question it // structurally cannot ask is the one that matters — // **can the caller this app actually has satisfy these guards, and is there // anything that still cannot?** Both halves, or the suite stays green on an app // that is either wide open or completely dead. // // The second failure is the easy one to ship by accident. A guard nobody can // satisfy is not strict security; it is a 100% outage wearing security's // clothes, and every unit test in this directory keeps passing while the // dashboard answers `ScopeError` to its own sign-in flow. So the subjects below // are built the way the framework builds them (`applyScopeDecision` over this // app's real `resolveScopes`), never by writing the scopes the assertions want. import { describe, it, expect } from 'vitest' import { anonymousSubject, applyScopeDecision, checkGuards, ScopeError, type Subject, } from '@voltro/protocol' import { MEMBER_SCOPES, resolveScopes } from '../authz' import { listProjects } from '../queries/projects.list.query' import { listInvites } from '../queries/invites.list.query' import { createProject } from '../mutations/projects.create.mutation' import { createInvite } from '../mutations/invites.create.mutation' import { me } from '../actions/me.action' /** The Subject a browser with no session cookie gets: `voltroPasswordStrategy` * finds nothing to match, so the composer falls back to anonymous. */ const visitor = anonymousSubject('acme') /** The Subject a signed-in member gets. The cookie resolves to a `user` Subject * carrying NO scopes (a cookie never carries authority), then the composer * applies this app's own `resolveScopes`. Built through the framework's * `applyScopeDecision` so this file cannot disagree with the runtime about how * a grant lands on a subject. */ const member = (): Subject => { const matched: Subject = { type: 'user', id: 'user_1', tenantId: 'acme', scopes: [] } return applyScopeDecision(matched, { kind: 'grant', scopes: resolveScopes(matched) }).subject } /** Exactly what the dispatch spine asks before an executor runs — and, for the * two creates, before `requireEntitlement` can spend the tenant's quota. */ const decide = ( descriptor: { readonly name: string; readonly guards?: ReadonlyArray | undefined }, subject: Subject, ): ScopeError | null => checkGuards( subject, descriptor.guards as Parameters[1], { defaultDeny: true, procedure: descriptor.name }, ) const GUARDED = [listProjects, listInvites, createProject, createInvite] as const describe('the caller this app actually has — a signed-in member', () => { // If any of these regress the dashboard boots and shows nothing but denials, // which no other test in this directory can see. it.each(GUARDED.map((d) => [d.name, d] as const))('%s is callable', (_name, descriptor) => { expect(decide(descriptor, member())).toBeNull() }) it('is not just passing because the guards are empty', () => { // The silent-zero shape: `checkGuards` with no guards returns null too. for (const d of GUARDED) expect(d.guards?.length ?? 0).toBeGreaterThan(0) expect(GUARDED).toHaveLength(4) }) }) describe('the caller with no session — every domain procedure is refused', () => { it.each(GUARDED.map((d) => [d.name, d] as const))('%s denies an anonymous visitor', (_name, descriptor) => { expect(decide(descriptor, visitor)).toBeInstanceOf(ScopeError) }) it('so a stranger cannot read teammate emails or spend a seat', () => { // The two that carry real cost. `invites.list` returns email addresses; // `invites.create` consumes the tenant's metered `seats` entitlement, and // the guard runs BEFORE that gate — a quota is not an access control. expect(decide(listInvites, visitor)).toBeInstanceOf(ScopeError) expect(decide(createInvite, visitor)).toBeInstanceOf(ScopeError) }) it('…and that is structural, not a resolver quirk', () => { // Pinned so a framework change would surface here: `applyScopeDecision` // returns an anonymous Subject UNTOUCHED under both decision kinds (the // schema has no `scopes` field to write). `auth.resolveScopes` also never // runs for one — it fires only on a MATCHED subject — so a session cookie // is the only way into the granted set. const granted = applyScopeDecision(visitor, { kind: 'grant', scopes: [...MEMBER_SCOPES] }) expect(granted.subject).toEqual(visitor) expect(decide(createProject, granted.subject)).toBeInstanceOf(ScopeError) }) }) describe('session.me stays open, and that is the point', () => { it('answers an anonymous caller instead of denying them', () => { // A guard here would return `ScopeError` to the dashboard's SSR gate, which // would then have nothing to branch on and would never redirect to /login. expect(decide(me, visitor)).toBeNull() expect(me.openAccess).toBeTruthy() }) }) describe('the vocabulary is a boundary, not a rubber stamp', () => { it('grants no wildcard and no admin bypass', () => { expect(MEMBER_SCOPES).not.toContain('*') expect(MEMBER_SCOPES).not.toContain('admin:full') }) it('separates inviting a person from creating a project', () => { // Split by blast radius: one spends a seat and lets an outsider into the // tenant, the other does not. Collapsing them into one scope is what makes // a later `owner` role impossible to express. const scopeOf = (d: { readonly guards?: ReadonlyArray | undefined }): string => ((d.guards ?? []) as ReadonlyArray<{ scope?: string }>)[0]?.scope ?? '' expect(scopeOf(createInvite)).not.toBe(scopeOf(createProject)) }) it('every guard names a scope the resolver actually grants', () => { // The other half of unsatisfiability: a typo'd scope makes a procedure // permanently uncallable, and nothing else in this app would notice. const granted = new Set(resolveScopes(member())) for (const d of GUARDED) { for (const g of (d.guards ?? []) as ReadonlyArray<{ scope?: string }>) { expect(granted.has(g.scope ?? '')).toBe(true) } } }) })