// Authorization + descriptor tests, driven through the REAL rbac plugin. // // `makeTestContext({ plugins: [...] })` + `invoke` composes this app's actual // interceptor chain and enforces each descriptor's `guards:` with the same // `checkGuardsEffect` the serve pipeline calls — so we exercise the whole path // (tenant → role → compiled scope → guard), not a hand-stamped `subject.scopes`. // The asymmetry that defines a status page is the thing under test: reads are // public, writes are operator-only. // // Run with `voltro test` (vitest). import { describe, it, expect } from 'vitest' import { Schema } from 'effect' import { makeTestContext, mockStore, invoke } from '@voltro/testing' import { rbacPlugin } from '@voltro/plugin-rbac' import type { Subject } from '@voltro/protocol' import { roles, demoRolesForTenant } from '../authz' import { database } from '../database/schema' // registers incidents / components / updates / core import { createIncident } from '../mutations/incidents.create.mutation' import createIncidentHandler from '../mutations/incidents.create.mutation.server' import { incidentsLive } from '../queries/incidents.live.query' import incidentsLiveHandler from '../queries/incidents.live.query.server' /** The app's real plugin, built from the app's real role map. */ const plugin = () => rbacPlugin({ roles, resolveRoles: (subject) => demoRolesForTenant(subject.tenantId) }) const subjectFor = (tenantId: string | null, id: string | null = 'u_1', type = 'user'): Subject => ({ type, id, tenantId, scopes: [] }) as Subject const ctxFor = (subject: Subject) => makeTestContext({ subject, store: mockStore({ incidents: [], components: [], incident_updates: [] }), plugins: [plugin()] }) describe('incidents.create — operator-gated', () => { it('an operator (tenant `ops`) may declare an incident', async () => { const ctx = ctxFor(subjectFor('ops')) const inc = await invoke(createIncident, createIncidentHandler, { title: 'API degraded', impact: 'major' }, ctx) expect(inc.title).toBe('API degraded') expect(inc.status).toBe('investigating') }) it('an anonymous visitor is refused with a typed ScopeError, and nothing is written', async () => { const ctx = ctxFor(subjectFor(null, null, 'anonymous')) await expect(invoke(createIncident, createIncidentHandler, { title: 'nope', impact: 'minor' }, ctx)) .rejects.toMatchObject({ _tag: 'ScopeError', required: 'status:write' }) expect(await ctx.store.query(database.incidents.descriptor)).toHaveLength(0) }) it('a signed-in NON-operator is also refused', async () => { const ctx = ctxFor(subjectFor('marketing')) await expect(invoke(createIncident, createIncidentHandler, { title: 'nope', impact: 'minor' }, ctx)) .rejects.toMatchObject({ _tag: 'ScopeError', required: 'status:write' }) }) }) describe('incidents.live — reads are PUBLIC', () => { it('an anonymous visitor may subscribe — the query declares `openAccess`', async () => { const ctx = ctxFor(subjectFor(null, null, 'anonymous')) await expect(invoke(incidentsLive, incidentsLiveHandler, {}, ctx)).resolves.toBeDefined() }) it('…which is a DECISION, not an omission', () => { // Before `openAccess` existed, "nobody decided" and "we decided it is open" // were the same descriptor and a reviewer could not tell them apart. Assert // the marker is present, or this suite would keep passing on a query whose // guard someone deleted by accident. expect(incidentsLive.openAccess).toBeTruthy() }) }) describe('incidents.create — descriptor', () => { it('accepts a valid impact and rejects an unknown one', () => { const decode = Schema.decodeUnknownSync(createIncident.input) expect(decode({ title: 'x', impact: 'critical' })).toEqual({ title: 'x', impact: 'critical' }) expect(() => decode({ title: 'x', impact: 'catastrophic' })).toThrow() expect(() => decode({ title: '', impact: 'minor' })).toThrow() }) })