Barazo AppView backend barazo.forum
at main 362 lines 11 kB view raw
1import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest' 2import Fastify from 'fastify' 3import type { FastifyInstance } from 'fastify' 4import { createAuthMiddleware } from '../../../src/auth/middleware.js' 5import type { RequestUser } from '../../../src/auth/middleware.js' 6import type { SessionService, Session } from '../../../src/auth/session.js' 7import type { DidDocumentVerifier } from '../../../src/lib/did-document-verifier.js' 8import type { Logger } from '../../../src/lib/logger.js' 9 10// --------------------------------------------------------------------------- 11// Standalone mock functions (avoids @typescript-eslint/unbound-method) 12// --------------------------------------------------------------------------- 13 14const validateAccessTokenFn = vi.fn<(...args: unknown[]) => Promise<Session | undefined>>() 15const verifyDidFn = 16 vi.fn<(...args: unknown[]) => Promise<{ active: true } | { active: false; reason: string }>>() 17 18function createMockSessionService(): SessionService { 19 return { 20 createSession: vi.fn(), 21 validateAccessToken: validateAccessTokenFn, 22 refreshSession: vi.fn(), 23 deleteSession: vi.fn(), 24 deleteAllSessionsForDid: vi.fn(), 25 } 26} 27 28function createMockDidVerifier(): DidDocumentVerifier { 29 return { 30 verify: verifyDidFn, 31 } 32} 33 34// Logger mock functions 35const logErrorFn = vi.fn() 36const logWarnFn = vi.fn() 37 38function createMockLogger(): Logger { 39 return { 40 info: vi.fn(), 41 error: logErrorFn, 42 warn: logWarnFn, 43 debug: vi.fn(), 44 fatal: vi.fn(), 45 trace: vi.fn(), 46 child: vi.fn(), 47 silent: vi.fn(), 48 level: 'silent', 49 } as unknown as Logger 50} 51 52// --------------------------------------------------------------------------- 53// Fixtures 54// --------------------------------------------------------------------------- 55 56const VALID_TOKEN = 'a'.repeat(64) 57const VALID_SESSION: Session = { 58 sid: 's'.repeat(64), 59 did: 'did:plc:abc123', 60 handle: 'jay.bsky.team', 61 accessTokenHash: 'h'.repeat(64), 62 accessTokenExpiresAt: Date.now() + 900_000, 63 createdAt: Date.now() - 60_000, 64} 65 66// --------------------------------------------------------------------------- 67// requireAuth tests 68// --------------------------------------------------------------------------- 69 70describe('requireAuth middleware', () => { 71 let app: FastifyInstance 72 73 beforeAll(async () => { 74 const mockSessionService = createMockSessionService() 75 const mockLogger = createMockLogger() 76 const mockDidVerifier = createMockDidVerifier() 77 78 const { requireAuth } = createAuthMiddleware(mockSessionService, mockDidVerifier, mockLogger) 79 80 app = Fastify({ logger: false }) 81 82 // Fastify requires decoration before hooks can set properties 83 app.decorateRequest('user', undefined) 84 85 app.get('/test', { preHandler: [requireAuth] }, (request) => { 86 return { user: request.user } 87 }) 88 89 await app.ready() 90 }) 91 92 afterAll(async () => { 93 await app.close() 94 }) 95 96 beforeEach(() => { 97 vi.clearAllMocks() 98 // Default: DID verification passes 99 verifyDidFn.mockResolvedValue({ active: true }) 100 }) 101 102 it('returns 401 for missing Authorization header', async () => { 103 const response = await app.inject({ 104 method: 'GET', 105 url: '/test', 106 }) 107 108 expect(response.statusCode).toBe(401) 109 expect(response.json<{ error: string }>()).toStrictEqual({ error: 'Authentication required' }) 110 }) 111 112 it('returns 401 for non-Bearer authorization scheme', async () => { 113 const response = await app.inject({ 114 method: 'GET', 115 url: '/test', 116 headers: { authorization: 'Basic dXNlcjpwYXNz' }, 117 }) 118 119 expect(response.statusCode).toBe(401) 120 expect(response.json<{ error: string }>()).toStrictEqual({ error: 'Authentication required' }) 121 }) 122 123 it('returns 401 for empty Bearer token', async () => { 124 const response = await app.inject({ 125 method: 'GET', 126 url: '/test', 127 headers: { authorization: 'Bearer ' }, 128 }) 129 130 expect(response.statusCode).toBe(401) 131 expect(response.json<{ error: string }>()).toStrictEqual({ error: 'Authentication required' }) 132 }) 133 134 it('returns 401 for invalid/expired token', async () => { 135 validateAccessTokenFn.mockResolvedValueOnce(undefined) 136 137 const response = await app.inject({ 138 method: 'GET', 139 url: '/test', 140 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 141 }) 142 143 expect(response.statusCode).toBe(401) 144 expect(response.json<{ error: string }>()).toStrictEqual({ error: 'Invalid or expired token' }) 145 expect(validateAccessTokenFn).toHaveBeenCalledWith(VALID_TOKEN) 146 }) 147 148 it('sets request.user and returns 200 for valid token with active DID', async () => { 149 validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) 150 verifyDidFn.mockResolvedValueOnce({ active: true }) 151 152 const response = await app.inject({ 153 method: 'GET', 154 url: '/test', 155 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 156 }) 157 158 expect(response.statusCode).toBe(200) 159 160 const body = response.json<{ user: RequestUser }>() 161 expect(body.user).toStrictEqual({ 162 did: VALID_SESSION.did, 163 handle: VALID_SESSION.handle, 164 sid: VALID_SESSION.sid, 165 }) 166 expect(validateAccessTokenFn).toHaveBeenCalledWith(VALID_TOKEN) 167 expect(verifyDidFn).toHaveBeenCalledWith(VALID_SESSION.did) 168 }) 169 170 it('returns 401 when DID is deactivated/tombstoned', async () => { 171 validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) 172 verifyDidFn.mockResolvedValueOnce({ active: false, reason: 'DID has been tombstoned' }) 173 174 const response = await app.inject({ 175 method: 'GET', 176 url: '/test', 177 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 178 }) 179 180 expect(response.statusCode).toBe(401) 181 expect(response.json<{ error: string }>()).toStrictEqual({ 182 error: 'DID is no longer active', 183 }) 184 }) 185 186 it('returns 502 when DID verification fails with resolution error', async () => { 187 validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) 188 verifyDidFn.mockResolvedValueOnce({ 189 active: false, 190 reason: 'DID document resolution failed', 191 }) 192 193 const response = await app.inject({ 194 method: 'GET', 195 url: '/test', 196 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 197 }) 198 199 expect(response.statusCode).toBe(502) 200 expect(response.json<{ error: string }>()).toStrictEqual({ 201 error: 'Service temporarily unavailable', 202 }) 203 }) 204 205 it('returns 502 when DID verifier throws', async () => { 206 validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) 207 verifyDidFn.mockRejectedValueOnce(new Error('Unexpected error')) 208 209 const response = await app.inject({ 210 method: 'GET', 211 url: '/test', 212 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 213 }) 214 215 expect(response.statusCode).toBe(502) 216 expect(response.json<{ error: string }>()).toStrictEqual({ 217 error: 'Service temporarily unavailable', 218 }) 219 expect(logErrorFn).toHaveBeenCalledOnce() 220 }) 221 222 it('returns 502 when sessionService throws', async () => { 223 validateAccessTokenFn.mockRejectedValueOnce(new Error('Valkey connection lost')) 224 225 const response = await app.inject({ 226 method: 'GET', 227 url: '/test', 228 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 229 }) 230 231 expect(response.statusCode).toBe(502) 232 expect(response.json<{ error: string }>()).toStrictEqual({ 233 error: 'Service temporarily unavailable', 234 }) 235 expect(logErrorFn).toHaveBeenCalledOnce() 236 }) 237}) 238 239// --------------------------------------------------------------------------- 240// optionalAuth tests 241// --------------------------------------------------------------------------- 242 243describe('optionalAuth middleware', () => { 244 let app: FastifyInstance 245 246 beforeAll(async () => { 247 const mockSessionService = createMockSessionService() 248 const mockLogger = createMockLogger() 249 const mockDidVerifier = createMockDidVerifier() 250 251 const { optionalAuth } = createAuthMiddleware(mockSessionService, mockDidVerifier, mockLogger) 252 253 app = Fastify({ logger: false }) 254 255 // Fastify requires decoration before hooks can set properties 256 app.decorateRequest('user', undefined) 257 258 app.get('/test', { preHandler: [optionalAuth] }, (request) => { 259 return { user: request.user ?? null } 260 }) 261 262 await app.ready() 263 }) 264 265 afterAll(async () => { 266 await app.close() 267 }) 268 269 beforeEach(() => { 270 vi.clearAllMocks() 271 // Default: DID verification passes 272 verifyDidFn.mockResolvedValue({ active: true }) 273 }) 274 275 it('sets request.user for valid token with active DID', async () => { 276 validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) 277 verifyDidFn.mockResolvedValueOnce({ active: true }) 278 279 const response = await app.inject({ 280 method: 'GET', 281 url: '/test', 282 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 283 }) 284 285 expect(response.statusCode).toBe(200) 286 287 const body = response.json<{ user: RequestUser }>() 288 expect(body.user).toStrictEqual({ 289 did: VALID_SESSION.did, 290 handle: VALID_SESSION.handle, 291 sid: VALID_SESSION.sid, 292 }) 293 }) 294 295 it('continues with request.user undefined for missing Authorization header', async () => { 296 const response = await app.inject({ 297 method: 'GET', 298 url: '/test', 299 }) 300 301 expect(response.statusCode).toBe(200) 302 expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) 303 }) 304 305 it('continues with request.user undefined for invalid token', async () => { 306 validateAccessTokenFn.mockResolvedValueOnce(undefined) 307 308 const response = await app.inject({ 309 method: 'GET', 310 url: '/test', 311 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 312 }) 313 314 expect(response.statusCode).toBe(200) 315 expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) 316 }) 317 318 it('continues with request.user undefined when DID is deactivated', async () => { 319 validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) 320 verifyDidFn.mockResolvedValueOnce({ active: false, reason: 'DID has been tombstoned' }) 321 322 const response = await app.inject({ 323 method: 'GET', 324 url: '/test', 325 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 326 }) 327 328 expect(response.statusCode).toBe(200) 329 expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) 330 expect(logWarnFn).toHaveBeenCalledOnce() 331 }) 332 333 it('continues with request.user undefined when DID verifier throws', async () => { 334 validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) 335 verifyDidFn.mockRejectedValueOnce(new Error('Unexpected error')) 336 337 const response = await app.inject({ 338 method: 'GET', 339 url: '/test', 340 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 341 }) 342 343 expect(response.statusCode).toBe(200) 344 expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) 345 expect(logWarnFn).toHaveBeenCalledOnce() 346 }) 347 348 it('continues with request.user undefined when sessionService throws and logs warning', async () => { 349 validateAccessTokenFn.mockRejectedValueOnce(new Error('Valkey connection lost')) 350 351 const response = await app.inject({ 352 method: 'GET', 353 url: '/test', 354 headers: { authorization: `Bearer ${VALID_TOKEN}` }, 355 }) 356 357 expect(response.statusCode).toBe(200) 358 expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) 359 360 expect(logWarnFn).toHaveBeenCalledOnce() 361 }) 362})