Skip to content
File

Blob: tests/worker/services/security.test.ts

typescript166 lines
1import { describe, expect, it } from "vitest";
2import {
3 decodeSessionRecord,
4 decodeWebSocketTicket,
5 isAllowedWebSocketOrigin,
6 isLoopbackHostname,
7 withSecurityHeaders,
8} from "@/worker/security";
9 
10describe("withSecurityHeaders", () => {
11 it("sets CSP, no-store, and HSTS headers for HTML over HTTPS", () => {
12 const request = new Request("https://flamemail.devbin.tools/");
13 const response = new Response("<html></html>", {
14 headers: {
15 "content-type": "text/html; charset=utf-8",
16 },
17 });
18 
19 const secured = withSecurityHeaders(request, response);
20 
21 expect(secured.headers.get("content-security-policy")).toContain("default-src 'self'");
22 expect(secured.headers.get("cache-control")).toBe("no-store, max-age=0");
23 expect(secured.headers.get("pragma")).toBe("no-cache");
24 expect(secured.headers.get("strict-transport-security")).toBe("max-age=31536000; includeSubDomains");
25 expect(secured.headers.get("referrer-policy")).toBe("no-referrer");
26 });
27 
28 it("sets no-store headers for API responses without adding CSP", () => {
29 const request = new Request("https://flamemail.devbin.tools/api/public/config");
30 const response = new Response(JSON.stringify({ ok: true }), {
31 headers: {
32 "content-type": "application/json",
33 },
34 });
35 
36 const secured = withSecurityHeaders(request, response);
37 
38 expect(secured.headers.get("cache-control")).toBe("no-store, max-age=0");
39 expect(secured.headers.get("pragma")).toBe("no-cache");
40 expect(secured.headers.get("content-security-policy")).toBeNull();
41 });
42 
43 it("does not set HSTS for insecure requests", () => {
44 const request = new Request("http://flamemail.devbin.tools/");
45 const response = new Response("<html></html>", {
46 headers: {
47 "content-type": "text/html",
48 },
49 });
50 
51 const secured = withSecurityHeaders(request, response);
52 
53 expect(secured.headers.get("strict-transport-security")).toBeNull();
54 });
55});
56 
57describe("isAllowedWebSocketOrigin", () => {
58 it("accepts matching origins", () => {
59 const request = new Request("https://flamemail.devbin.tools/ws", {
60 headers: {
61 origin: "https://flamemail.devbin.tools",
62 },
63 });
64 
65 expect(isAllowedWebSocketOrigin(request)).toBe(true);
66 });
67 
68 it("rejects missing origins", () => {
69 const request = new Request("https://flamemail.devbin.tools/ws");
70 
71 expect(isAllowedWebSocketOrigin(request)).toBe(false);
72 });
73 
74 it("rejects mismatched origins", () => {
75 const request = new Request("https://flamemail.devbin.tools/ws", {
76 headers: {
77 origin: "https://evil.example",
78 },
79 });
80 
81 expect(isAllowedWebSocketOrigin(request)).toBe(false);
82 });
83});
84 
85describe("isLoopbackHostname", () => {
86 it("accepts local hostnames used by URL.hostname", () => {
87 expect(isLoopbackHostname("localhost")).toBe(true);
88 expect(isLoopbackHostname("127.0.0.1")).toBe(true);
89 expect(isLoopbackHostname("[::1]")).toBe(true);
90 expect(isLoopbackHostname("::1")).toBe(true);
91 });
92 
93 it("rejects non-loopback hostnames", () => {
94 expect(isLoopbackHostname("auth.example")).toBe(false);
95 expect(isLoopbackHostname("192.0.2.10")).toBe(false);
96 });
97});
98 
99describe("decodeWebSocketTicket", () => {
100 it("decodes valid ticket payloads", () => {
101 const raw = JSON.stringify({
102 address: "user@example.com",
103 session: {
104 type: "user",
105 address: "user@example.com",
106 },
107 });
108 
109 expect(decodeWebSocketTicket(raw)).toEqual({
110 address: "user@example.com",
111 session: {
112 type: "user",
113 address: "user@example.com",
114 },
115 });
116 });
117 
118 it("returns null for invalid json and invalid shapes", () => {
119 expect(decodeWebSocketTicket("{")).toBeNull();
120 expect(
121 decodeWebSocketTicket(
122 JSON.stringify({
123 address: "user@example.com",
124 session: {
125 type: "user",
126 },
127 }),
128 ),
129 ).toBeNull();
130 });
131});
132 
133describe("decodeSessionRecord", () => {
134 it("decodes valid session payloads", () => {
135 expect(
136 decodeSessionRecord(
137 JSON.stringify({
138 type: "admin",
139 sub: "00000000-0000-4000-8000-000000000001",
140 }),
141 ),
142 ).toEqual({
143 type: "admin",
144 sub: "00000000-0000-4000-8000-000000000001",
145 });
146 });
147 
148 it("returns null for invalid json and invalid shapes", () => {
149 expect(decodeSessionRecord("{")).toBeNull();
150 expect(
151 decodeSessionRecord(
152 JSON.stringify({
153 type: "admin",
154 }),
155 ),
156 ).toBeNull();
157 expect(
158 decodeSessionRecord(
159 JSON.stringify({
160 type: "user",
161 }),
162 ),
163 ).toBeNull();
164 });
165});