FastAPI and TypeScript improved
This commit is contained in:
163
backends/mizan-ts/tests/auth.test.ts
Normal file
163
backends/mizan-ts/tests/auth.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
126
backends/mizan-ts/tests/token.test.ts
Normal file
126
backends/mizan-ts/tests/token.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* MWT decode tests — round-trip + cross-language pin against Python create_mwt.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { createHmac } from 'crypto'
|
||||
import { decodeMwt, decodeJwtBearer, identityFromMwt } from '../src'
|
||||
|
||||
function b64url(buf: Buffer | string): string {
|
||||
return Buffer.from(buf).toString('base64url')
|
||||
}
|
||||
|
||||
/** Mint an HS256 MWT with node crypto, mirroring Python create_mwt. */
|
||||
function mint(payload: Record<string, any>, secret: string, kid = 'v1'): string {
|
||||
const header = b64url(JSON.stringify({ alg: 'HS256', kid, typ: 'JWT' }))
|
||||
const body = b64url(JSON.stringify(payload))
|
||||
const sig = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url')
|
||||
return `${header}.${body}.${sig}`
|
||||
}
|
||||
|
||||
const SECRET = 'round-trip-secret'
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
function basePayload(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
sub: '7',
|
||||
staff: true,
|
||||
super: false,
|
||||
pkey: 'abc123',
|
||||
aud: 'mizan',
|
||||
iat: now,
|
||||
nbf: now,
|
||||
exp: now + 300,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MWT round-trip', () => {
|
||||
test('valid token decodes', () => {
|
||||
const token = mint(basePayload(), SECRET)
|
||||
const p = decodeMwt(token, SECRET)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.sub).toBe('7')
|
||||
expect(p!.staff).toBe(true)
|
||||
expect(p!.super).toBe(false)
|
||||
expect(p!.pkey).toBe('abc123')
|
||||
expect(p!.kid).toBe('v1')
|
||||
expect(p!.aud).toBe('mizan')
|
||||
})
|
||||
|
||||
test('identityFromMwt maps claims', () => {
|
||||
const token = mint(basePayload({ sub: '99', staff: false, super: true }), SECRET)
|
||||
const p = decodeMwt(token, SECRET)!
|
||||
expect(identityFromMwt(p)).toEqual({
|
||||
isAuthenticated: true,
|
||||
isStaff: false,
|
||||
isSuperuser: true,
|
||||
id: 99,
|
||||
})
|
||||
})
|
||||
|
||||
test('decodeJwtBearer strips Bearer prefix', () => {
|
||||
const token = mint(basePayload(), SECRET)
|
||||
const p = decodeJwtBearer(`Bearer ${token}`, SECRET)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.sub).toBe('7')
|
||||
})
|
||||
|
||||
test('null on tampered signature', () => {
|
||||
const token = mint(basePayload(), SECRET)
|
||||
const tampered = token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA')
|
||||
expect(decodeMwt(tampered, SECRET)).toBeNull()
|
||||
})
|
||||
|
||||
test('null on wrong secret', () => {
|
||||
const token = mint(basePayload(), SECRET)
|
||||
expect(decodeMwt(token, 'other-secret')).toBeNull()
|
||||
})
|
||||
|
||||
test('null on expired exp', () => {
|
||||
const token = mint(basePayload({ exp: now - 10 }), SECRET)
|
||||
expect(decodeMwt(token, SECRET)).toBeNull()
|
||||
})
|
||||
|
||||
test('null on future nbf', () => {
|
||||
const token = mint(basePayload({ nbf: now + 1000 }), SECRET)
|
||||
expect(decodeMwt(token, SECRET)).toBeNull()
|
||||
})
|
||||
|
||||
test('null on wrong aud', () => {
|
||||
const token = mint(basePayload({ aud: 'other' }), SECRET)
|
||||
expect(decodeMwt(token, SECRET)).toBeNull()
|
||||
})
|
||||
|
||||
test('null on malformed token', () => {
|
||||
expect(decodeMwt('not.a.jwt', SECRET)).toBeNull()
|
||||
expect(decodeMwt('onlyonepart', SECRET)).toBeNull()
|
||||
expect(decodeMwt('', SECRET)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MWT cross-language pin (Python create_mwt)', () => {
|
||||
const TOKEN = 'eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJzdWIiOiI0MiIsInN0YWZmIjp0cnVlLCJzdXBlciI6ZmFsc2UsInBrZXkiOiIwZTk5OGE5ZmYxNjkwNDYzN2EwM2QyZWEwZmJkYmY5NzQyOTdhOWQxYTVkMjViOGQ0Mjk0ZmE4ODIxMTVlNDU3IiwiYXVkIjoibWl6YW4iLCJpYXQiOjE3MDAwMDAwMDAsIm5iZiI6MTcwMDAwMDAwMCwiZXhwIjo0MTAyNDQ0ODAwfQ._V92JXiLSLXoyuSwbNvvJjwzgmczmC7dvX34kVSLIa8'
|
||||
const PIN_SECRET = 'pin-test-secret-mwt'
|
||||
|
||||
test('decodes the Python-minted token', () => {
|
||||
const p = decodeMwt(TOKEN, PIN_SECRET)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.sub).toBe('42')
|
||||
expect(p!.staff).toBe(true)
|
||||
expect(p!.super).toBe(false)
|
||||
expect(p!.pkey).toBe('0e998a9ff16904637a03d2ea0fbdbf974297a9d1a5d25b8d4294fa882115e457')
|
||||
expect(p!.kid).toBe('v1')
|
||||
expect(p!.aud).toBe('mizan')
|
||||
})
|
||||
|
||||
test('identity from Python-minted token', () => {
|
||||
const p = decodeMwt(TOKEN, PIN_SECRET)!
|
||||
expect(identityFromMwt(p)).toEqual({
|
||||
isAuthenticated: true,
|
||||
isStaff: true,
|
||||
isSuperuser: false,
|
||||
id: 42,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user