/** * Auth-parity tests — mirrors Django's auth enforcement in * mizan-django/src/mizan/client/executor.py (_check_auth_requirement). */ import { describe, test, expect, beforeEach } from 'bun:test' import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, setCache, resetCache, setCacheSecret, MemoryCache, type Identity, } from '../src' function anon(): Identity { return { isAuthenticated: false, isStaff: false, isSuperuser: false, id: null } } function user(): Identity { return { isAuthenticated: true, isStaff: false, isSuperuser: false, id: 1 } } function staff(): Identity { return { isAuthenticated: true, isStaff: true, isSuperuser: false, id: 2 } } function superuser(): Identity { return { isAuthenticated: true, isStaff: true, isSuperuser: true, id: 3 } } describe('Auth — mutation dispatch', () => { beforeEach(() => clearRegistry()) test('auth:true + anon → 401', async () => { client({ auth: true }, async function secret() { return { ok: true } }) const r = await handleMutationCall('secret', {}, anon()) expect(r.status).toBe(401) expect(r.body.code).toBe('UNAUTHORIZED') expect(r.body.message).toBe('Authentication required') expect(r.headers['Cache-Control']).toBe('no-store') }) test('auth:true + user → 200', async () => { client({ auth: true }, async function secret() { return { ok: true } }) const r = await handleMutationCall('secret', {}, user()) expect(r.status).toBe(200) expect(r.body.result).toEqual({ ok: true }) }) test("auth:'staff' + user → 403", async () => { client({ auth: 'staff' }, async function adminAction() { return { ok: true } }) const r = await handleMutationCall('adminAction', {}, user()) expect(r.status).toBe(403) expect(r.body.code).toBe('FORBIDDEN') expect(r.body.message).toBe('Staff access required') }) test("auth:'staff' + staff → 200", async () => { client({ auth: 'staff' }, async function adminAction() { return { ok: true } }) const r = await handleMutationCall('adminAction', {}, staff()) expect(r.status).toBe(200) }) test("auth:'superuser' + staff → 403", async () => { client({ auth: 'superuser' }, async function nuke() { return { ok: true } }) const r = await handleMutationCall('nuke', {}, staff()) expect(r.status).toBe(403) expect(r.body.message).toBe('Superuser access required') }) test("auth:'superuser' + superuser → 200", async () => { client({ auth: 'superuser' }, async function nuke() { return { ok: true } }) const r = await handleMutationCall('nuke', {}, superuser()) expect(r.status).toBe(200) }) test('callable → true → 200', async () => { client({ auth: (id) => id.isAuthenticated }, async function gated() { return { ok: true } }) const r = await handleMutationCall('gated', {}, user()) expect(r.status).toBe(200) }) test("callable → false → 403 'Access denied'", async () => { client({ auth: () => false }, async function gated() { return { ok: true } }) const r = await handleMutationCall('gated', {}, user()) expect(r.status).toBe(403) expect(r.body.message).toBe('Access denied') }) test("callable throws Error('msg') → 403 'msg'", async () => { client({ auth: () => { throw new Error('msg') } }, async function gated() { return { ok: true } }) const r = await handleMutationCall('gated', {}, user()) expect(r.status).toBe(403) expect(r.body.message).toBe('msg') }) test('callable runs before authentication gate (anon allowed if predicate true)', async () => { client({ auth: () => true }, async function gated() { return { ok: true } }) const r = await handleMutationCall('gated', {}, anon()) expect(r.status).toBe(200) }) test('invalid auth string at decoration → throws', () => { expect(() => { client({ auth: 'admin' as any }, async function bad() { return {} }) }).toThrow('Invalid auth value') }) test('no auth + anon → 200 (default ANONYMOUS path stays open)', async () => { client({}, async function open() { return { ok: true } }) const r = await handleMutationCall('open', {}) expect(r.status).toBe(200) }) }) describe('Auth — context fetch', () => { beforeEach(() => clearRegistry()) test('auth-gated context member + anon → 401', async () => { const Ctx = new ReactContext('secure') client({ context: Ctx, auth: true }, async function secureData(itemId: number) { return { id: itemId } }) const r = await handleContextFetch('secure', { itemId: '1' }, anon()) expect(r.status).toBe(401) expect(r.body.message).toBe('Authentication required') }) test('auth-gated context + user → 200', async () => { const Ctx = new ReactContext('secure') client({ context: Ctx, auth: true }, async function secureData(itemId: number) { return { id: itemId } }) const r = await handleContextFetch('secure', { itemId: '1' }, user()) expect(r.status).toBe(200) expect(r.body.secureData).toEqual({ id: '1' }) }) test('context fetch denial pre-empts a would-be cache HIT', async () => { const SECRET = 'auth-test-secret-32bytes-padding!' const Ctx = new ReactContext('secure') client({ context: Ctx, auth: true }, async function secureData(itemId: number) { return { id: itemId } }) const cache = new MemoryCache() setCache(cache) setCacheSecret(SECRET) // Prime the cache as an authorized caller. const primed = await handleContextFetch('secure', { itemId: '1' }, user()) expect(primed.status).toBe(200) expect(primed.headers['X-Mizan-Cache']).toBe('MISS') // Confirm it's now a cache HIT for an authorized caller. const hit = await handleContextFetch('secure', { itemId: '1' }, user()) expect(hit.headers['X-Mizan-Cache']).toBe('HIT') // Anon must get 401 even though the cache holds the entry. const denied = await handleContextFetch('secure', { itemId: '1' }, anon()) expect(denied.status).toBe(401) expect(denied.headers['X-Mizan-Cache']).toBeUndefined() resetCache() setCacheSecret(null) }) })