diff --git a/src/core/middleware.ts b/src/core/middleware.ts index ad859ed..b633c2a 100644 --- a/src/core/middleware.ts +++ b/src/core/middleware.ts @@ -57,7 +57,7 @@ export const publicRateLimiter = async ( ) => { const ip = getClientIp(c); const key = `ratelimit:public:${ip}`; - const allowed = await checkRateLimit(key, 10, 60000); + const allowed = await rateLimitWrapper.checkRateLimit(key, 10, 60000); if (!allowed) { return c.json({ error: "Too Many Requests" }, 429); } @@ -74,7 +74,7 @@ export const adminRateLimiter = async ( } const key = `ratelimit:admin:${auth.userId}`; - const allowed = await checkRateLimit(key, 60, 60000); + const allowed = await rateLimitWrapper.checkRateLimit(key, 60, 60000); if (!allowed) { return c.json({ error: "Too Many Requests" }, 429); } diff --git a/src/features/auth/auth.test.ts b/src/features/auth/auth.test.ts index 2f510c2..4a6b4f1 100644 --- a/src/features/auth/auth.test.ts +++ b/src/features/auth/auth.test.ts @@ -2,6 +2,9 @@ import { test } from "jsr:@std/testing/bdd"; import { expect } from "jsr:@std/expect"; import { Hono } from "jsr:@hono/hono@4"; 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 () => { 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"); }); -test("login challenge requires JSON payload", async () => { - const app = new Hono(); - app.route("/", authRoutes); +test("login challenge generates valid WebAuthn options for user with passkey", async () => { + const originalSql = sqlWrapper.sql; + const originalRateLimit = rateLimitWrapper.checkRateLimit; + const originalValkeySetex = valkey.setex; + const originalRpId = Deno.env.get("RP_ID"); - const res = await app.request("/api/login/challenge", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ username: "test" }), - }); + try { + Deno.env.set("RP_ID", "localhost"); - // Mock DB isn't loaded so it might fail with 500, but we just verify it routed - expect(res.status).not.toBe(404); + // Hermetic mocks + 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); + } + } });