Barazo AppView backend barazo.forum
at main 303 lines 9.4 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 2import Fastify from 'fastify' 3import type { FastifyInstance } from 'fastify' 4import { createRequireOperator } from '../../../src/auth/require-operator.js' 5import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' 6import type { Env } from '../../../src/config/env.js' 7import type { Logger } from '../../../src/lib/logger.js' 8 9// --------------------------------------------------------------------------- 10// Mock auth middleware 11// --------------------------------------------------------------------------- 12 13function createMockAuthMiddleware(): AuthMiddleware { 14 return { 15 requireAuth: vi.fn(async (_request, _reply) => { 16 // Simulate setting user - tests will set request.user before calling 17 }), 18 optionalAuth: vi.fn(), 19 } 20} 21 22// --------------------------------------------------------------------------- 23// Mock logger 24// --------------------------------------------------------------------------- 25 26const logInfoFn = vi.fn() 27const logWarnFn = vi.fn() 28 29function createMockLogger(): Logger { 30 return { 31 info: logInfoFn, 32 error: vi.fn(), 33 warn: logWarnFn, 34 debug: vi.fn(), 35 fatal: vi.fn(), 36 trace: vi.fn(), 37 child: vi.fn(), 38 silent: vi.fn(), 39 level: 'silent', 40 } as unknown as Logger 41} 42 43// --------------------------------------------------------------------------- 44// Mock env 45// --------------------------------------------------------------------------- 46 47function createMockEnv( 48 overrides: Partial<Pick<Env, 'COMMUNITY_MODE' | 'OPERATOR_DIDS'>> = {} 49): Pick<Env, 'COMMUNITY_MODE' | 'OPERATOR_DIDS'> { 50 return { 51 COMMUNITY_MODE: overrides.COMMUNITY_MODE ?? 'multi', 52 OPERATOR_DIDS: overrides.OPERATOR_DIDS ?? ['did:plc:operator123'], 53 } 54} 55 56// --------------------------------------------------------------------------- 57// Fixtures 58// --------------------------------------------------------------------------- 59 60const OPERATOR_USER: RequestUser = { 61 did: 'did:plc:operator123', 62 handle: 'operator.bsky.social', 63 sid: 's'.repeat(64), 64} 65 66const NON_OPERATOR_USER: RequestUser = { 67 did: 'did:plc:user456', 68 handle: 'user.bsky.social', 69 sid: 's'.repeat(64), 70} 71 72// --------------------------------------------------------------------------- 73// Tests 74// --------------------------------------------------------------------------- 75 76describe('requireOperator middleware', () => { 77 let app: FastifyInstance 78 let mockAuthMiddleware: AuthMiddleware 79 80 afterEach(async () => { 81 await app.close() 82 }) 83 84 beforeEach(() => { 85 vi.clearAllMocks() 86 }) 87 88 // Helper to build a Fastify app with the operator middleware 89 async function buildApp( 90 envOverrides: Partial<Pick<Env, 'COMMUNITY_MODE' | 'OPERATOR_DIDS'>> = {}, 91 withLogger = true 92 ): Promise<FastifyInstance> { 93 mockAuthMiddleware = createMockAuthMiddleware() 94 const mockEnv = createMockEnv(envOverrides) 95 const mockLogger = withLogger ? createMockLogger() : undefined 96 97 const requireOperator = createRequireOperator(mockEnv as Env, mockAuthMiddleware, mockLogger) 98 99 app = Fastify({ logger: false }) 100 app.decorateRequest('user', undefined as RequestUser | undefined) 101 102 app.get('/operator-test', { preHandler: [requireOperator] }, (request) => { 103 return { user: request.user } 104 }) 105 106 await app.ready() 107 return app 108 } 109 110 // ------------------------------------------------------------------------- 111 // Community mode check 112 // ------------------------------------------------------------------------- 113 114 it("returns 404 if COMMUNITY_MODE is 'single'", async () => { 115 await buildApp({ COMMUNITY_MODE: 'single' }) 116 117 const response = await app.inject({ 118 method: 'GET', 119 url: '/operator-test', 120 }) 121 122 expect(response.statusCode).toBe(404) 123 expect(response.json<{ error: string }>()).toStrictEqual({ 124 error: 'Not found', 125 }) 126 // requireAuth should NOT have been called 127 expect(mockAuthMiddleware.requireAuth).not.toHaveBeenCalled() 128 }) 129 130 // ------------------------------------------------------------------------- 131 // Authentication check (delegated to requireAuth) 132 // ------------------------------------------------------------------------- 133 134 it('returns 401 when requireAuth rejects (no token)', async () => { 135 await buildApp({ COMMUNITY_MODE: 'multi' }) 136 137 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, reply) => { 138 await reply.status(401).send({ error: 'Authentication required' }) 139 }) 140 141 const response = await app.inject({ 142 method: 'GET', 143 url: '/operator-test', 144 }) 145 146 expect(response.statusCode).toBe(401) 147 expect(response.json<{ error: string }>()).toStrictEqual({ 148 error: 'Authentication required', 149 }) 150 }) 151 152 // ------------------------------------------------------------------------- 153 // Operator DID check 154 // ------------------------------------------------------------------------- 155 156 it('returns 403 if user DID is not in OPERATOR_DIDS', async () => { 157 await buildApp({ 158 COMMUNITY_MODE: 'multi', 159 OPERATOR_DIDS: ['did:plc:operator123'], 160 }) 161 162 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { 163 request.user = NON_OPERATOR_USER 164 }) 165 166 const response = await app.inject({ 167 method: 'GET', 168 url: '/operator-test', 169 }) 170 171 expect(response.statusCode).toBe(403) 172 expect(response.json<{ error: string }>()).toStrictEqual({ 173 error: 'Operator access required', 174 }) 175 }) 176 177 it('returns 403 when requireAuth passes but request.user is not set', async () => { 178 await buildApp({ COMMUNITY_MODE: 'multi' }) 179 180 // requireAuth passes without setting request.user 181 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, _reply) => { 182 // intentionally do not set request.user 183 }) 184 185 const response = await app.inject({ 186 method: 'GET', 187 url: '/operator-test', 188 }) 189 190 expect(response.statusCode).toBe(403) 191 expect(response.json<{ error: string }>()).toStrictEqual({ 192 error: 'Operator access required', 193 }) 194 }) 195 196 // ------------------------------------------------------------------------- 197 // Success path 198 // ------------------------------------------------------------------------- 199 200 it("grants access if user DID is in OPERATOR_DIDS and mode is 'multi'", async () => { 201 await buildApp({ 202 COMMUNITY_MODE: 'multi', 203 OPERATOR_DIDS: ['did:plc:operator123'], 204 }) 205 206 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { 207 request.user = OPERATOR_USER 208 }) 209 210 const response = await app.inject({ 211 method: 'GET', 212 url: '/operator-test', 213 }) 214 215 expect(response.statusCode).toBe(200) 216 const body = response.json<{ user: RequestUser }>() 217 expect(body.user).toStrictEqual(OPERATOR_USER) 218 }) 219 220 it('grants access when OPERATOR_DIDS contains multiple DIDs', async () => { 221 await buildApp({ 222 COMMUNITY_MODE: 'multi', 223 OPERATOR_DIDS: ['did:plc:other999', 'did:plc:operator123', 'did:plc:another888'], 224 }) 225 226 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { 227 request.user = OPERATOR_USER 228 }) 229 230 const response = await app.inject({ 231 method: 'GET', 232 url: '/operator-test', 233 }) 234 235 expect(response.statusCode).toBe(200) 236 const body = response.json<{ user: RequestUser }>() 237 expect(body.user).toStrictEqual(OPERATOR_USER) 238 }) 239 240 // ------------------------------------------------------------------------- 241 // Audit logging 242 // ------------------------------------------------------------------------- 243 244 it('logs audit trail when operator access is denied (DID not in list)', async () => { 245 await buildApp({ 246 COMMUNITY_MODE: 'multi', 247 OPERATOR_DIDS: ['did:plc:operator123'], 248 }) 249 250 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { 251 request.user = NON_OPERATOR_USER 252 }) 253 254 await app.inject({ 255 method: 'GET', 256 url: '/operator-test', 257 }) 258 259 expect(logWarnFn).toHaveBeenCalledWith( 260 { did: NON_OPERATOR_USER.did, url: '/operator-test', method: 'GET' }, 261 'Operator access denied: DID not in OPERATOR_DIDS' 262 ) 263 }) 264 265 it('logs audit trail when operator access is denied (no user after auth)', async () => { 266 await buildApp({ COMMUNITY_MODE: 'multi' }) 267 268 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (_request, _reply) => { 269 // intentionally do not set request.user 270 }) 271 272 await app.inject({ 273 method: 'GET', 274 url: '/operator-test', 275 }) 276 277 expect(logWarnFn).toHaveBeenCalledWith( 278 { url: '/operator-test', method: 'GET' }, 279 'Operator access denied: no user after auth' 280 ) 281 }) 282 283 it('logs audit trail when operator access is granted', async () => { 284 await buildApp({ 285 COMMUNITY_MODE: 'multi', 286 OPERATOR_DIDS: ['did:plc:operator123'], 287 }) 288 289 vi.mocked(mockAuthMiddleware.requireAuth).mockImplementation(async (request, _reply) => { 290 request.user = OPERATOR_USER 291 }) 292 293 await app.inject({ 294 method: 'GET', 295 url: '/operator-test', 296 }) 297 298 expect(logInfoFn).toHaveBeenCalledWith( 299 { did: OPERATOR_USER.did, url: '/operator-test', method: 'GET' }, 300 'Operator access granted' 301 ) 302 }) 303})