Barazo AppView backend
barazo.forum
1import { describe, it, expect, beforeAll, afterAll } from 'vitest'
2import Fastify from 'fastify'
3import type { FastifyInstance } from 'fastify'
4import helmet from '@fastify/helmet'
5
6/**
7 * Tests for Content Security Policy configuration.
8 *
9 * API routes get a strict CSP (no 'unsafe-inline', no CDN allowlisting).
10 * The /docs scope gets a permissive CSP for the Scalar API reference UI,
11 * which requires inline scripts/styles and CDN assets.
12 */
13describe('Content Security Policy', () => {
14 let app: FastifyInstance
15
16 // Permissive CSP for docs scope (must match app.ts DOCS_CSP)
17 const docsCsp = [
18 "default-src 'self'",
19 "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
20 "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
21 "img-src 'self' data: https:",
22 "connect-src 'self'",
23 "font-src 'self' https://cdn.jsdelivr.net",
24 "object-src 'none'",
25 "frame-src 'none'",
26 "base-uri 'self'",
27 "form-action 'self'",
28 "frame-ancestors 'none'",
29 ].join('; ')
30
31 beforeAll(async () => {
32 app = Fastify({ logger: false })
33
34 // Strict global CSP (mirrors app.ts helmet config)
35 await app.register(helmet, {
36 contentSecurityPolicy: {
37 directives: {
38 defaultSrc: ["'self'"],
39 scriptSrc: ["'self'"],
40 styleSrc: ["'self'"],
41 imgSrc: ["'self'", 'data:', 'https:'],
42 connectSrc: ["'self'"],
43 fontSrc: ["'self'"],
44 objectSrc: ["'none'"],
45 frameSrc: ["'none'"],
46 baseUri: ["'self'"],
47 formAction: ["'self'"],
48 frameAncestors: ["'none'"],
49 },
50 },
51 })
52
53 // Simulate an API route
54 app.get('/api/test', () => ({ ok: true }))
55
56 // Simulate a non-API, non-docs route
57 app.get('/health', () => ({ status: 'ok' }))
58
59 // Docs scope with permissive CSP override (mirrors app.ts docsPlugin)
60 await app.register(function docsScope(scope, _opts, done) {
61 scope.addHook('onRequest', (_request, reply, hookDone) => {
62 reply.header('content-security-policy', docsCsp)
63 hookDone()
64 })
65 scope.get('/docs', (_request, reply) => {
66 return reply.type('text/html').send('<html><body>docs</body></html>')
67 })
68 done()
69 })
70
71 await app.ready()
72 })
73
74 afterAll(async () => {
75 await app.close()
76 })
77
78 describe('API routes (strict CSP)', () => {
79 it('does not include unsafe-inline in script-src', async () => {
80 const response = await app.inject({ method: 'GET', url: '/api/test' })
81 const csp = response.headers['content-security-policy'] as string
82 expect(csp).toBeDefined()
83 expect(csp).not.toContain('unsafe-inline')
84 })
85
86 it('does not allow cdn.jsdelivr.net', async () => {
87 const response = await app.inject({ method: 'GET', url: '/api/test' })
88 const csp = response.headers['content-security-policy'] as string
89 expect(csp).not.toContain('cdn.jsdelivr.net')
90 })
91
92 it('restricts script-src to self only', async () => {
93 const response = await app.inject({ method: 'GET', url: '/api/test' })
94 const csp = response.headers['content-security-policy'] as string
95 expect(csp).toContain("script-src 'self'")
96 })
97
98 it('restricts style-src to self only', async () => {
99 const response = await app.inject({ method: 'GET', url: '/api/test' })
100 const csp = response.headers['content-security-policy'] as string
101 expect(csp).toContain("style-src 'self'")
102 })
103 })
104
105 describe('docs routes (permissive CSP for Scalar)', () => {
106 it('allows unsafe-inline for scripts', async () => {
107 const response = await app.inject({ method: 'GET', url: '/docs' })
108 const csp = response.headers['content-security-policy'] as string
109 expect(csp).toContain("'unsafe-inline'")
110 expect(csp).toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net")
111 })
112
113 it('allows unsafe-inline for styles', async () => {
114 const response = await app.inject({ method: 'GET', url: '/docs' })
115 const csp = response.headers['content-security-policy'] as string
116 expect(csp).toContain("style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net")
117 })
118
119 it('allows cdn.jsdelivr.net for fonts', async () => {
120 const response = await app.inject({ method: 'GET', url: '/docs' })
121 const csp = response.headers['content-security-policy'] as string
122 expect(csp).toContain("font-src 'self' https://cdn.jsdelivr.net")
123 })
124 })
125
126 describe('protective directives on all routes', () => {
127 it('sets base-uri on API routes', async () => {
128 const response = await app.inject({ method: 'GET', url: '/api/test' })
129 const csp = response.headers['content-security-policy'] as string
130 expect(csp).toContain("base-uri 'self'")
131 })
132
133 it('sets form-action on API routes', async () => {
134 const response = await app.inject({ method: 'GET', url: '/api/test' })
135 const csp = response.headers['content-security-policy'] as string
136 expect(csp).toContain("form-action 'self'")
137 })
138
139 it('sets frame-ancestors to none on API routes', async () => {
140 const response = await app.inject({ method: 'GET', url: '/api/test' })
141 const csp = response.headers['content-security-policy'] as string
142 expect(csp).toContain("frame-ancestors 'none'")
143 })
144
145 it('sets base-uri on docs routes', async () => {
146 const response = await app.inject({ method: 'GET', url: '/docs' })
147 const csp = response.headers['content-security-policy'] as string
148 expect(csp).toContain("base-uri 'self'")
149 })
150
151 it('sets form-action on docs routes', async () => {
152 const response = await app.inject({ method: 'GET', url: '/docs' })
153 const csp = response.headers['content-security-policy'] as string
154 expect(csp).toContain("form-action 'self'")
155 })
156
157 it('sets frame-ancestors to none on docs routes', async () => {
158 const response = await app.inject({ method: 'GET', url: '/docs' })
159 const csp = response.headers['content-security-policy'] as string
160 expect(csp).toContain("frame-ancestors 'none'")
161 })
162
163 it('applies strict CSP to non-API routes outside docs scope', async () => {
164 const response = await app.inject({ method: 'GET', url: '/health' })
165 const csp = response.headers['content-security-policy'] as string
166 expect(csp).toBeDefined()
167 expect(csp).not.toContain('unsafe-inline')
168 expect(csp).not.toContain('cdn.jsdelivr.net')
169 })
170 })
171})