Barazo AppView backend
barazo.forum
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})