// 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 the directory keeps passing while the editor // 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 to see. import { describe, it, expect } from 'vitest' import { anonymousSubject, applyScopeDecision, checkGuards, ScopeError, type Subject, } from '@voltro/protocol' import { EDITOR_SCOPES, resolveScopes } from '../authz' import { listContent } from '../queries/content.list.query' import { getContent } from '../actions/content.get.action' import { contentTypes } from '../actions/content.types.action' import { me } from '../actions/me.action' import { saveDraft } from '../mutations/content.saveDraft.mutation' import { publishContent } from '../mutations/content.publish.mutation' import { unpublishContent } from '../mutations/content.unpublish.mutation' /** 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 editor gets. `voltroPasswordStrategy` matches the * cookie into a `user` Subject carrying NO scopes (a cookie never carries * authority), then the composer applies this app's own `resolveScopes` to it. * Built through the framework's `applyScopeDecision` so this file cannot * disagree with the runtime about how a grant lands on a subject. */ const editor = (): Subject => { const matched: Subject = { type: 'user', id: 'usr_editor', tenantId: 'acme', scopes: [] } return applyScopeDecision(matched, { kind: 'grant', scopes: resolveScopes(matched) }).subject } /** Exactly what the dispatch spine asks before an executor runs. */ 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 = [listContent, getContent, contentTypes, saveDraft, publishContent, unpublishContent] as const describe('the caller this app actually has — a signed-in editor', () => { // If any of these regress the editor 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, editor())).toBeNull() }) it('is not just passing because the guards are empty', () => { // The silent-zero shape: `checkGuards` with no guards would return null too. // Assert the decisions are really there before trusting the line above. for (const d of GUARDED) expect(d.guards?.length ?? 0).toBeGreaterThan(0) expect(GUARDED).toHaveLength(6) }) }) describe('the caller with no session — every content 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('…and that is structural, not a resolver quirk', () => { // The reason an anonymous caller can never hold `content:write` in this app, // 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 cookie is the // only way into the granted set. const granted = applyScopeDecision(visitor, { kind: 'grant', scopes: [...EDITOR_SCOPES] }) expect(granted.subject).toEqual(visitor) expect(decide(saveDraft, 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 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(EDITOR_SCOPES).not.toContain('*') expect(EDITOR_SCOPES).not.toContain('admin:full') }) 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(editor())) for (const d of GUARDED) { for (const g of (d.guards ?? []) as ReadonlyArray<{ scope?: string }>) { expect(granted.has(g.scope ?? '')).toBe(true) } } }) })