142 lines
3.6 KiB
TypeScript
142 lines
3.6 KiB
TypeScript
|
|
import Fastify from 'fastify'
|
||
|
|
import apophisPlugin from '@apophis/fastify'
|
||
|
|
import crypto from 'crypto'
|
||
|
|
|
||
|
|
const fastify = Fastify()
|
||
|
|
|
||
|
|
import { createAuthExtension } from '@apophis/fastify/extension/factories'
|
||
|
|
|
||
|
|
const authExtension = createAuthExtension({
|
||
|
|
name: 'bearer',
|
||
|
|
acquire: async () => {
|
||
|
|
// In real apps, this would call a login endpoint
|
||
|
|
const token = crypto.randomBytes(32).toString('hex')
|
||
|
|
return { token, userId: 'tester-1' }
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
await fastify.register(apophisPlugin, {
|
||
|
|
runtime: 'error',
|
||
|
|
extensions: [authExtension],
|
||
|
|
})
|
||
|
|
|
||
|
|
const sessions = new Map<string, { userId: string; createdAt: number }>()
|
||
|
|
|
||
|
|
// LOGIN — acquires a session token
|
||
|
|
fastify.post('/auth/login', {
|
||
|
|
schema: {
|
||
|
|
'x-category': 'constructor',
|
||
|
|
'x-ensures': [
|
||
|
|
'response_body(this).token != null',
|
||
|
|
'response_body(this).expiresAt > request_time(this)',
|
||
|
|
],
|
||
|
|
body: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
username: { type: 'string' },
|
||
|
|
password: { type: 'string' },
|
||
|
|
},
|
||
|
|
required: ['username', 'password'],
|
||
|
|
},
|
||
|
|
response: {
|
||
|
|
200: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
token: { type: 'string' },
|
||
|
|
expiresAt: { type: 'number' },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, async (req) => {
|
||
|
|
const token = crypto.randomBytes(48).toString('hex')
|
||
|
|
const expiresAt = Date.now() + 3600_000
|
||
|
|
sessions.set(token, { userId: `usr-${req.body.username}`, createdAt: Date.now() })
|
||
|
|
return { token, expiresAt }
|
||
|
|
})
|
||
|
|
|
||
|
|
// PROTECTED RESOURCE — requires valid auth
|
||
|
|
fastify.get('/auth/me', {
|
||
|
|
schema: {
|
||
|
|
'x-category': 'observer',
|
||
|
|
'x-requires': [
|
||
|
|
'response_status(this) == 200',
|
||
|
|
],
|
||
|
|
'x-ensures': [
|
||
|
|
'response_body(this).userId != null',
|
||
|
|
'response_body(this).authenticated == true',
|
||
|
|
],
|
||
|
|
headers: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
authorization: { type: 'string', pattern: '^Bearer ' },
|
||
|
|
},
|
||
|
|
required: ['authorization'],
|
||
|
|
},
|
||
|
|
response: {
|
||
|
|
200: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
userId: { type: 'string' },
|
||
|
|
authenticated: { type: 'boolean' },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, async (req, reply) => {
|
||
|
|
const header = req.headers.authorization
|
||
|
|
if (!header) {
|
||
|
|
reply.status(401)
|
||
|
|
return { error: 'Missing Authorization header' }
|
||
|
|
}
|
||
|
|
const token = header.replace('Bearer ', '')
|
||
|
|
const session = sessions.get(token)
|
||
|
|
if (!session) {
|
||
|
|
reply.status(401)
|
||
|
|
return { error: 'Invalid or expired token' }
|
||
|
|
}
|
||
|
|
return { userId: session.userId, authenticated: true }
|
||
|
|
})
|
||
|
|
|
||
|
|
// LOGOUT — destroys a session, must not succeed twice
|
||
|
|
fastify.post('/auth/logout', {
|
||
|
|
schema: {
|
||
|
|
'x-category': 'destructor',
|
||
|
|
'x-requires': [
|
||
|
|
'response_status(this) == 200',
|
||
|
|
],
|
||
|
|
'x-ensures': [
|
||
|
|
// After logout, the same token should be rejected
|
||
|
|
'response_code(GET /auth/me) == 401',
|
||
|
|
],
|
||
|
|
headers: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
authorization: { type: 'string', pattern: '^Bearer ' },
|
||
|
|
},
|
||
|
|
required: ['authorization'],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, async (req, reply) => {
|
||
|
|
const header = req.headers.authorization
|
||
|
|
if (!header) {
|
||
|
|
reply.status(401)
|
||
|
|
return { error: 'Missing Authorization header' }
|
||
|
|
}
|
||
|
|
const token = header.replace('Bearer ', '')
|
||
|
|
const existed = sessions.delete(token)
|
||
|
|
if (!existed) {
|
||
|
|
reply.status(404)
|
||
|
|
return { error: 'Session not found' }
|
||
|
|
}
|
||
|
|
return { ok: true }
|
||
|
|
})
|
||
|
|
|
||
|
|
await fastify.ready()
|
||
|
|
|
||
|
|
const result = await fastify.apophis.contract({ runs: 30 })
|
||
|
|
console.log('Contract tests:', result.summary)
|
||
|
|
|
||
|
|
const stateful = await fastify.apophis.stateful({ runs: 30, seed: 42 })
|
||
|
|
console.log('Stateful tests:', stateful.summary)
|