test(auth): hermetically mock SQL and Valkey rate limiter to prevent connection leaks

This commit is contained in:
Tyler Gillispie 2026-08-27 21:02:10 -07:00
parent 11d4b80014
commit a8111f6dc3
2 changed files with 68 additions and 14 deletions

View File

@ -57,7 +57,7 @@ export const publicRateLimiter = async (
) => { ) => {
const ip = getClientIp(c); const ip = getClientIp(c);
const key = `ratelimit:public:${ip}`; const key = `ratelimit:public:${ip}`;
const allowed = await checkRateLimit(key, 10, 60000); const allowed = await rateLimitWrapper.checkRateLimit(key, 10, 60000);
if (!allowed) { if (!allowed) {
return c.json({ error: "Too Many Requests" }, 429); return c.json({ error: "Too Many Requests" }, 429);
} }
@ -74,7 +74,7 @@ export const adminRateLimiter = async (
} }
const key = `ratelimit:admin:${auth.userId}`; const key = `ratelimit:admin:${auth.userId}`;
const allowed = await checkRateLimit(key, 60, 60000); const allowed = await rateLimitWrapper.checkRateLimit(key, 60, 60000);
if (!allowed) { if (!allowed) {
return c.json({ error: "Too Many Requests" }, 429); return c.json({ error: "Too Many Requests" }, 429);
} }

View File

@ -2,6 +2,9 @@ import { test } from "jsr:@std/testing/bdd";
import { expect } from "jsr:@std/expect"; import { expect } from "jsr:@std/expect";
import { Hono } from "jsr:@hono/hono@4"; import { Hono } from "jsr:@hono/hono@4";
import { authRoutes } from "./routes.tsx"; import { authRoutes } from "./routes.tsx";
import { sqlWrapper } from "../../core/db.ts";
import { rateLimitWrapper } from "../../core/middleware.ts";
import { valkey } from "../../core/valkey.ts";
test("auth slice UI endpoints return HTML", async () => { test("auth slice UI endpoints return HTML", async () => {
const app = new Hono(); const app = new Hono();
@ -20,18 +23,69 @@ test("auth slice UI endpoints return HTML", async () => {
expect(res.headers.get("content-type")).toContain("text/html"); expect(res.headers.get("content-type")).toContain("text/html");
}); });
test("login challenge requires JSON payload", async () => { test("login challenge generates valid WebAuthn options for user with passkey", async () => {
const app = new Hono(); const originalSql = sqlWrapper.sql;
app.route("/", authRoutes); const originalRateLimit = rateLimitWrapper.checkRateLimit;
const originalValkeySetex = valkey.setex;
const originalRpId = Deno.env.get("RP_ID");
const res = await app.request("/api/login/challenge", { try {
method: "POST", Deno.env.set("RP_ID", "localhost");
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username: "test" }),
});
// Mock DB isn't loaded so it might fail with 500, but we just verify it routed // Hermetic mocks
expect(res.status).not.toBe(404); rateLimitWrapper.checkRateLimit = () => Promise.resolve(true);
valkey.setex = (() => Promise.resolve("OK")) as any;
const mockUser = {
id: "mock-user-123",
username: "alice",
account_status: "active",
};
const mockPasskey = {
id: "mock-passkey-1",
credential_id: "Y3JlZGVudGlhbC0x",
public_key: new Uint8Array([1, 2, 3]),
counter: 0,
transports: ["internal"],
user_id: "mock-user-123",
prf_enabled: false,
};
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
const query = strings.join("?");
if (query.includes("SELECT id, account_status FROM users WHERE username =")) {
return Promise.resolve([mockUser]);
}
if (query.includes("FROM passkeys WHERE user_id =")) {
return Promise.resolve([mockPasskey]);
}
return Promise.resolve([]);
}) as any;
const app = new Hono();
app.route("/", authRoutes);
const res = await app.request("/api/login/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username: "alice" }),
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.options).toBeDefined();
expect(data.options.challenge).toBeDefined();
expect(data.options.allowCredentials).toBeDefined();
expect(data.options.allowCredentials.length).toBe(1);
} finally {
sqlWrapper.sql = originalSql;
rateLimitWrapper.checkRateLimit = originalRateLimit;
valkey.setex = originalValkeySetex;
if (originalRpId) {
Deno.env.set("RP_ID", originalRpId);
}
}
}); });