// Authorization tests, driven through the REAL rbac plugin. // // The important detail is `makeTestContext({ plugins: [...] })` plus `invoke`: // that composes this app's actual interceptor chain with // `composeRpcInterceptors` and enforces the descriptor's `guards:` with the // same `checkGuardsEffect` the serve pipeline calls. So these tests exercise // the whole path — role slugs → compiled scopes → effective-scope seam → // guard — rather than asserting against hand-set `subject.scopes`. // // That distinction is the point. Putting the expected scopes on the subject // yourself tests the guard while ASSUMING the resolution that produces them, // which is the half that actually breaks: a role renamed in `authz.ts`, a // resolver that throws, a scope no role grants. Those all pass a test that // pre-stamps its own scopes and fail these. // // Run with `voltro test` (vitest). import { describe, it, expect, afterEach } from 'vitest' import { Schema } from 'effect' import { makeTestContext, mockStore, invoke } from '@voltro/testing' import { rbacPlugin } from '@voltro/plugin-rbac' import { mutationToRpc, ScopeError, setResourceScopeResolver, type Subject } from '@voltro/protocol' import { roles, rolesOnTeam, demoRolesForTenant } from '../authz' import { database } from '../database/schema' // registers notes / teams / actors / tenants import { createNote } from '../mutations/notes.create.mutation' import createNoteHandler from '../mutations/notes.create.mutation.server' import { deleteNote, NoteNotFound } from '../mutations/notes.delete.mutation' import deleteNoteHandler from '../mutations/notes.delete.mutation.server' import { renameTeam } from '../mutations/teams.rename.mutation' import renameTeamHandler from '../mutations/teams.rename.mutation.server' import { listNotes } from '../queries/notes.list.query' /** The app's real plugin, built from the app's real role map. */ const plugin = () => rbacPlugin({ roles, resolveRoles: (subject) => demoRolesForTenant(subject.tenantId), resolveResourceRoles: (subject, resource) => rolesOnTeam(subject.id, resource), }) /** A caller in the tenant that the demo resolver maps to `role`. */ const caller = (id: string, tenantId: string): Subject => ({ type: 'user', id, tenantId, scopes: [] }) as Subject const ctxFor = (subject: Subject, seed: Record>> = {}) => makeTestContext({ subject, store: mockStore({ notes: [], teams: [], ...seed }), plugins: [plugin()] }) // `rbacPlugin` registers its resource resolver process-globally; clear it so // cases stay isolated from each other. afterEach(() => { setResourceScopeResolver(undefined) }) describe('notes.create — a global declarative guard', () => { it('an editor holds notes:write, so the note is written', async () => { const ctx = ctxFor(caller('u_ed', 'editors')) const note = await invoke(createNote, createNoteHandler, { title: 'Ship it', body: 'x' }, ctx) expect(note.title).toBe('Ship it') expect(note.tenantId).toBe('editors') // tenant() auto-stamped from the subject expect(note.id).toMatch(/^note_/) }) it('a reader is refused with a typed ScopeError, and nothing is written', async () => { const ctx = ctxFor(caller('u_read', 'readers')) await expect(invoke(createNote, createNoteHandler, { title: 'nope', body: '' }, ctx)) .rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:write' }) // The refusal has to be a refusal — not an error thrown after the insert. const rows = await ctx.store.query(database.notes.descriptor) expect(rows).toHaveLength(0) }) it('the admin wildcard role passes', async () => { const ctx = ctxFor(caller('u_root', 'acme')) await expect(invoke(createNote, createNoteHandler, { title: 'ok', body: '' }, ctx)).resolves.toBeDefined() }) }) describe('notes.list — reads are guarded too', () => { it('a caller with no role at all cannot subscribe', async () => { // An unknown tenant falls to the resolver's `['viewer']` default, so use a // subject the app grants nothing: the guard must still hold. const ctx = makeTestContext({ subject: { type: 'user', id: 'u_x', tenantId: 'nobody', scopes: [] } as Subject, store: mockStore({ notes: [] }), plugins: [rbacPlugin({ roles, resolveRoles: () => [] })], }) await expect(invoke(listNotes, async () => [], {}, ctx)) .rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:read' }) }) it('a viewer may read', async () => { const ctx = ctxFor(caller('u_v', 'readers')) await expect(invoke(listNotes, async () => [], {}, ctx)).resolves.toEqual([]) }) }) describe('notes.delete — the decision the descriptor cannot make', () => { it('an editor lacks notes:delete entirely', async () => { const ctx = ctxFor(caller('u_ed', 'editors')) await expect(invoke(deleteNote, deleteNoteHandler, { id: 'note_1' }, ctx)) .rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:delete' }) }) it('an owner SOFT-deletes an active note (no notes:purge)', async () => { const ctx = ctxFor(caller('u_own', 'owners')) const row = await ctx.store.insert('notes', { title: 'live', body: '', archived: false }) const out = await invoke(deleteNote, deleteNoteHandler, { id: row['id'] as string }, ctx) expect(out.mode).toBe('soft') const rows = await ctx.store.query(database.notes.descriptor) expect(rows).toHaveLength(1) expect(rows[0]!['archived']).toBe(true) }) it('an owner HARD-deletes an already-archived note', async () => { const ctx = ctxFor(caller('u_own', 'owners')) const row = await ctx.store.insert('notes', { title: 'old', body: '', archived: true }) const out = await invoke(deleteNote, deleteNoteHandler, { id: row['id'] as string }, ctx) expect(out.mode).toBe('hard') expect(await ctx.store.query(database.notes.descriptor)).toHaveLength(0) }) it('admin holds notes:purge via the wildcard, so an active note is hard-deleted', async () => { const ctx = ctxFor(caller('u_root', 'acme')) const row = await ctx.store.insert('notes', { title: 'live', body: '', archived: false }) const out = await invoke(deleteNote, deleteNoteHandler, { id: row['id'] as string }, ctx) expect(out.mode).toBe('hard') }) it('a missing note fails with the app\'s own typed error, not a scope error', async () => { const ctx = ctxFor(caller('u_own', 'owners')) await expect(invoke(deleteNote, deleteNoteHandler, { id: 'note_gone' }, ctx)) .rejects.toBeInstanceOf(NoteNotFound) }) }) describe('teams.rename — a resource-scoped guard', () => { it('alice is owner OF team_core, so she may rename it', async () => { const ctx = ctxFor(caller('u_alice', 'nobody')) await ctx.store.insert('teams', { id: 'team_core', name: 'Core' }) const out = await invoke(renameTeam, renameTeamHandler, { teamId: 'team_core', name: 'Platform' }, ctx) expect(out.name).toBe('Platform') }) it('the SAME caller is refused on a team where she is only a viewer', async () => { // The whole reason resource scoping exists: the grant must not generalize. const ctx = ctxFor(caller('u_alice', 'nobody')) await ctx.store.insert('teams', { id: 'team_marketing', name: 'Marketing' }) await expect(invoke(renameTeam, renameTeamHandler, { teamId: 'team_marketing', name: 'Growth' }, ctx)) .rejects.toMatchObject({ _tag: 'ScopeError', required: 'teams:rename' }) }) it('a caller with no membership at all is refused', async () => { const ctx = ctxFor(caller('u_stranger', 'nobody')) await ctx.store.insert('teams', { id: 'team_core', name: 'Core' }) await expect(invoke(renameTeam, renameTeamHandler, { teamId: 'team_core', name: 'Mine' }, ctx)) .rejects.toMatchObject({ _tag: 'ScopeError' }) }) it('a GLOBAL grant satisfies it without consulting memberships', async () => { // `owners` tenant → the `owner` role → `teams:rename` globally. The // membership table is never asked, which is why an admin does not need a // row in it for every resource. const ctx = ctxFor(caller('u_nobody_in_memberships', 'owners')) await ctx.store.insert('teams', { id: 'team_new', name: 'New' }) await expect(invoke(renameTeam, renameTeamHandler, { teamId: 'team_new', name: 'Renamed' }, ctx)) .resolves.toMatchObject({ name: 'Renamed' }) }) }) describe('the declared scope vocabulary `voltro check` reads', () => { it('every scope any descriptor guard requires is granted by some role', () => { // This is the assertion `voltro check` makes at build time, kept here as // well because it is cheap and it fails with the offending scope named. // A guard requiring a scope no role grants is not "misconfigured" — that // procedure is permanently, silently uncallable by everyone. const declared = new Set(rbacPlugin({ roles }).declaredScopes ?? []) const guardScopes = [createNote, deleteNote, renameTeam, listNotes] .flatMap((d) => (d.guards ?? []).flatMap((g) => { const scope = (g as { scope?: string | ReadonlyArray }).scope return scope === undefined ? [] : typeof scope === 'string' ? [scope] : [...scope] })) expect(guardScopes.length).toBeGreaterThan(0) // the check must not be vacuous for (const scope of guardScopes) expect([...declared]).toContain(scope) }) it('the wildcard is not published as a literal scope name', () => { const declared = rbacPlugin({ roles }).declaredScopes ?? [] expect(declared).not.toContain('*') expect(declared).not.toContain('admin:full') }) }) describe('descriptor wire contracts', () => { it('notes.create decodes a valid input and rejects a blank title', () => { const decode = Schema.decodeUnknownSync(createNote.input) expect(decode({ title: 'ok', body: 'b' })).toEqual({ title: 'ok', body: 'b' }) expect(() => decode({ title: '', body: '' })).toThrow() }) it('a guarded descriptor carries ScopeError in the WIRE error union', () => { // The merge happens in `mutationToRpc` — the same conversion both the // server group and the generated client group go through — not on the raw // descriptor object. Asserting it on `createNote.error` would test the // wrong layer and pass for the wrong reason: that field is `Schema.Never` // here, because this descriptor declares no error of its own. // // This is why none of these descriptors writes `error: ScopeError` by hand // and a client can still branch on one denial tag. const wire = mutationToRpc(createNote) const denial = new ScopeError({ required: 'notes:write', message: 'nope' }) // `Rpc`'s `errorSchema` is typed as the broad `Schema.All`; narrow it to // the context-free form `Schema.is` takes. expect(Schema.is(wire.errorSchema as Schema.Schema.AnyNoContext)(denial)).toBe(true) }) })