164 lines
6.4 KiB
TypeScript
164 lines
6.4 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|