WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { describe, it, expect, vi, afterEach } from "vitest";
2import { Hono } from "hono";
3import type { Variables } from "../../types.js";
4import { requireNotBanned } from "../require-not-banned.js";
5import type { AppContext } from "../../lib/app-context.js";
6import { createMockLogger } from "../../lib/__tests__/mock-logger.js";
7
8// Mock getActiveBans so tests don't need a real database
9vi.mock("../../routes/helpers.js", () => ({
10 getActiveBans: vi.fn(),
11}));
12
13// Import after mocking
14const { getActiveBans } = await import("../../routes/helpers.js");
15const mockGetActiveBans = vi.mocked(getActiveBans);
16
17const mockLogger = createMockLogger();
18const stubCtx = { logger: mockLogger } as unknown as AppContext;
19
20/**
21 * Build a minimal Hono app with requireNotBanned middleware.
22 * The route handler sets c.get("user") before the middleware if user is provided.
23 */
24function makeApp(user: any | null): Hono<{ Variables: Variables }> {
25 const app = new Hono<{ Variables: Variables }>();
26
27 // Simulate requireAuth by pre-setting the user variable
28 app.use("/test", async (c, next) => {
29 if (user !== null) {
30 c.set("user", user);
31 }
32 await next();
33 });
34
35 app.post("/test", requireNotBanned(stubCtx), (c) => {
36 return c.json({ ok: true }, 200);
37 });
38
39 return app;
40}
41
42const mockUser = {
43 did: "did:plc:test-user",
44 handle: "testuser.test",
45 pdsUrl: "https://test.pds",
46 agent: {},
47};
48
49afterEach(() => {
50 vi.restoreAllMocks();
51});
52
53describe("requireNotBanned", () => {
54 it("returns 401 when no authenticated user is set", async () => {
55 // No user set (requireAuth was not run or failed)
56 const app = makeApp(null);
57
58 const res = await app.request("/test", { method: "POST" });
59
60 expect(res.status).toBe(401);
61 const data = await res.json();
62 expect(data.error).toBe("Authentication required");
63 });
64
65 it("returns 403 when user is actively banned", async () => {
66 mockGetActiveBans.mockResolvedValueOnce(new Set([mockUser.did]));
67
68 const app = makeApp(mockUser);
69 const res = await app.request("/test", { method: "POST" });
70
71 expect(res.status).toBe(403);
72 const data = await res.json();
73 expect(data.error).toBe("You are banned from this forum");
74 });
75
76 it("passes through to next handler when user is not banned", async () => {
77 mockGetActiveBans.mockResolvedValueOnce(new Set());
78
79 const app = makeApp(mockUser);
80 const res = await app.request("/test", { method: "POST" });
81
82 expect(res.status).toBe(200);
83 const data = await res.json();
84 expect(data.ok).toBe(true);
85 });
86
87 it("returns 503 when ban check fails with database error (fail closed)", async () => {
88 const spy = vi.spyOn(stubCtx.logger, "error");
89 mockGetActiveBans.mockRejectedValueOnce(new Error("database query failed"));
90
91 const app = makeApp(mockUser);
92 const res = await app.request("/test", { method: "POST" });
93
94 expect(res.status).toBe(503);
95 const data = await res.json();
96 expect(data.error).toBe(
97 "Database temporarily unavailable. Please try again later."
98 );
99
100 expect(spy).toHaveBeenCalledWith(
101 "Unable to verify ban status",
102 expect.objectContaining({
103 userId: mockUser.did,
104 error: "database query failed",
105 })
106 );
107 });
108
109 it("returns 503 when ban check fails with network error (fail closed)", async () => {
110 mockGetActiveBans.mockRejectedValueOnce(new Error("fetch failed"));
111
112 const app = makeApp(mockUser);
113 const res = await app.request("/test", { method: "POST" });
114
115 expect(res.status).toBe(503);
116 const data = await res.json();
117 expect(data.error).toBe(
118 "Unable to reach external service. Please try again later."
119 );
120 });
121
122 it("returns 500 when ban check fails with unexpected error (fail closed)", async () => {
123 const spy = vi.spyOn(stubCtx.logger, "error");
124 mockGetActiveBans.mockRejectedValueOnce(new Error("Unexpected internal error"));
125
126 const app = makeApp(mockUser);
127 const res = await app.request("/test", { method: "POST" });
128
129 expect(res.status).toBe(500);
130 const data = await res.json();
131 expect(data.error).toBe(
132 "Unable to verify ban status. Please contact support if this persists."
133 );
134
135 expect(spy).toHaveBeenCalledWith(
136 "Unable to verify ban status",
137 expect.objectContaining({
138 userId: mockUser.did,
139 error: "Unexpected internal error",
140 })
141 );
142 });
143
144 it("re-throws TypeError from ban check (programming error)", async () => {
145 const spy = vi.spyOn(stubCtx.logger, "error");
146 const programmingError = new TypeError("Cannot read property 'has' of undefined");
147 mockGetActiveBans.mockImplementationOnce(() => {
148 throw programmingError;
149 });
150
151 const app = makeApp(mockUser);
152 // Hono's global error handler catches the re-throw and returns 500
153 const res = await app.request("/test", { method: "POST" });
154 expect(res.status).toBe(500);
155
156 // CRITICAL log emitted before re-throw
157 expect(spy).toHaveBeenCalledWith(
158 expect.stringContaining("CRITICAL: Programming error in"),
159 expect.objectContaining({
160 userId: mockUser.did,
161 error: "Cannot read property 'has' of undefined",
162 stack: expect.any(String),
163 })
164 );
165
166 // Normal error log was NOT emitted (re-throw bypasses it)
167 expect(spy).not.toHaveBeenCalledWith(
168 "Unable to verify ban status",
169 expect.any(Object)
170 );
171 });
172});