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
at root/atb-56-theme-caching-layer 172 lines 5.4 kB view raw
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});