import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { eq } from "drizzle-orm"; import { createApi } from "../api"; import * as schema from "../db/schema"; import { hashToken, normalizeTimezone } from "./index"; import { readAuthConfig, type AuthConfig } from "./config"; import type { DiscordProfile, FetchDiscord } from "./discord"; const origin = "http://127.0.0.1:3000"; const config: AuthConfig = { origin, clientId: "test-client", clientSecret: "test-secret", cookieSecret: "test-only-signing-secret-at-least-32-characters" }; let sqlite: Database; let db: ReturnType>; let app: ReturnType; let timestamp: number; let calls: { url: string; init: RequestInit }[]; let profile: DiscordProfile; let failure: "token" | "invalid_token" | "profile" | "malformed" | "throw" | undefined; const request: FetchDiscord = async (url, init) => { calls.push({ url, init }); if (failure === "throw") throw new Error("provider secret response"); if (url.endsWith("/token")) { if (failure === "token") return new Response("provider secret response", { status: 400 }); if (failure === "invalid_token") return Response.json({ access_token: "secret-access", token_type: "Bearer", scope: "email" }); return Response.json({ access_token: "secret-access", refresh_token: "secret-refresh", token_type: "Bearer", scope: "identify" }); } if (failure === "profile") return new Response("provider secret response", { status: 503 }); return Response.json(failure === "malformed" ? { id: 123, username: "bad" } : profile); }; const cookie = (response: Response, name: string) => response.headers.getSetCookie().find(value => value.startsWith(`${name}=`))?.split(";")[0] ?? ""; async function begin(timezone = "Europe/Belgrade", target = app, base = origin) { const response = await target.request(`${base}/api/auth/discord?${new URLSearchParams({ timezone })}`); const location = new URL(response.headers.get("Location")!); return { response, state: location.searchParams.get("state")!, cookie: cookie(response, base.startsWith("https:") ? "__Host-minabot_oauth" : "minabot_oauth") }; } async function login(timezone = "Europe/Belgrade", previous = "") { const flow = await begin(timezone); const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: flow.state, code: "test-code" })}`, { headers: { Cookie: [flow.cookie, previous].filter(Boolean).join("; ") }, }); return { response, cookie: cookie(response, "minabot_session") }; } beforeEach(() => { sqlite = new Database(":memory:"); sqlite.exec("PRAGMA foreign_keys = ON"); db = drizzle(sqlite, { schema }); migrate(db, { migrationsFolder: "./drizzle" }); timestamp = Date.now(); calls = []; failure = undefined; profile = { id: "123456789012345678", username: "mina", global_name: "Mina", avatar: null }; app = createApi(db, config, request, () => timestamp); }); afterEach(() => sqlite.close()); describe("Discord sign-in and sessions", () => { test("trusted TLS proxy supports sign-in and secure callback cookies", async () => { const publicOrigin = "https://habits.example"; const proxyApp = createApi(db, { ...config, origin: publicOrigin, trustProxy: true }, request, () => timestamp); const response = await proxyApp.request("http://habits.example/api/auth/discord", { headers: { "X-Forwarded-Proto": "https" } }); const location = new URL(response.headers.get("Location")!); expect(location.origin).toBe("https://discord.com"); expect(location.searchParams.get("redirect_uri")).toBe(`${publicOrigin}/api/auth/discord/callback`); expect(response.headers.get("Set-Cookie")).toContain("Secure"); const callback = await proxyApp.request(`http://habits.example/api/auth/discord/callback?state=${location.searchParams.get("state")}&code=test-code`, { headers: { "X-Forwarded-Proto": "https", Cookie: cookie(response, "__Host-minabot_oauth") }, }); expect(cookie(callback, "__Host-minabot_session")).not.toBe(""); expect(callback.headers.get("Set-Cookie")).toContain("Secure"); }); test("forwarded protocol is opt-in and cannot override the actual hostname", async () => { for (const [trustProxy, host] of [[false, "habits.example"], [true, "wrong.example"]] as const) { const proxyApp = createApi(db, { ...config, origin: "https://habits.example", trustProxy }, request, () => timestamp); const response = await proxyApp.request(`http://${host}/api/auth/discord`, { headers: { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "habits.example" }, }); expect(new URL(response.headers.get("Location")!).origin).toBe("https://habits.example"); expect(response.headers.get("Set-Cookie")).toBeNull(); } }); test("me requires a valid session and health stays public", async () => { for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) { const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } }); expect(response.status).toBe(401); expect(await response.json()).toEqual({ error: "Unauthorized" }); expect(response.headers.get("Cache-Control")).toBe("no-store"); } expect((await app.request(`${origin}/api/health`)).status).toBe(200); expect((await app.request(`${origin}/api/missing`)).status).toBe(404); }); test("authorization uses identify, random state, and a signed expiring HttpOnly cookie", async () => { const first = await begin(); const second = await begin(); const url = new URL(first.response.headers.get("Location")!); expect(url.origin).toBe("https://discord.com"); expect(url.searchParams.get("scope")).toBe("identify"); expect(url.searchParams.get("redirect_uri")).toBe(`${origin}/api/auth/discord/callback`); expect(first.state).not.toBe(second.state); const header = first.response.headers.get("Set-Cookie")!; expect(header).toContain("HttpOnly"); expect(header).toContain("SameSite=Lax"); expect(header).toContain("Max-Age=600"); }); test("callback creates a user and hashed session, and me returns only public fields", async () => { const result = await login(); expect(result.response.headers.get("Location")).toBe("/"); const user = db.select().from(schema.users).get()!; const session = db.select().from(schema.sessions).get()!; expect(user.timezone).toBe("Europe/Belgrade"); expect(session.tokenHash).toBe(hashToken(result.cookie.split("=")[1]!)); expect(session.expiresAt - session.createdAt).toBe(30 * 24 * 60 * 60 * 1000); expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=2592000"); expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=0"); const response = await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ id: user.id, discordId: profile.id, username: "mina", displayName: "Mina", avatarUrl: null, timezone: "Europe/Belgrade" }); const body = calls[0]!.init.body as URLSearchParams; expect(body.get("client_secret")).toBe(config.clientSecret); expect(body.get("code")).toBe("test-code"); expect(calls[1]!.init.headers).toEqual({ Authorization: "Bearer secret-access" }); expect(JSON.stringify({ user, session })).not.toContain("secret-access"); }); test("repeat login updates profile but preserves ID, creation time and timezone; rotates session", async () => { const first = await login(); const oldUser = db.select().from(schema.users).get()!; timestamp += 1000; profile = { ...profile, username: "new-name", global_name: null, avatar: "a_abcdef" }; const second = await login("America/New_York", first.cookie); expect(db.select().from(schema.users).all()).toHaveLength(1); const user = db.select().from(schema.users).get()!; expect(user.id).toBe(oldUser.id); expect(user.createdAt).toBe(oldUser.createdAt); expect(user.updatedAt).toBe(timestamp); expect(user.timezone).toBe("Europe/Belgrade"); expect(db.select().from(schema.sessions).all()).toHaveLength(1); expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401); const response = await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } }); const data = await response.json(); expect(data.displayName).toBe("new-name"); expect(data.avatarUrl).toBe(`https://cdn.discordapp.com/avatars/${profile.id}/a_abcdef.gif`); }); test("invalid or missing timezone falls back to UTC", async () => { expect(normalizeTimezone(undefined)).toBe("UTC"); expect(normalizeTimezone("+02:00")).toBe("UTC"); expect(normalizeTimezone("Europe/Belgrade")).toBe("Europe/Belgrade"); await login("invalid/timezone"); expect(db.select().from(schema.users).get()!.timezone).toBe("UTC"); }); test("rejects missing, mismatched, tampered and expired OAuth state before contacting Discord", async () => { const flow = await begin(); const cases = [ { state: flow.state, cookie: "" }, { state: "b".repeat(43), cookie: flow.cookie }, { state: flow.state, cookie: `${flow.cookie}tampered` }, { state: "", cookie: flow.cookie }, ]; for (const value of cases) { const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: value.state, code: "test-code" })}`, { headers: { Cookie: value.cookie } }); expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state"); } timestamp += 600_000; const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } }); expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state"); expect(calls).toHaveLength(0); expect(db.select().from(schema.users).all()).toHaveLength(0); }); test("handles consent denial and missing code without creating a session", async () => { for (const [query, error] of [["error=access_denied", "denied"], ["", "invalid_code"]]) { const flow = await begin(); const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&${query}`, { headers: { Cookie: flow.cookie } }); expect(response.headers.get("Location")).toBe(`/?auth_error=${error}`); } expect(calls).toHaveLength(0); expect(db.select().from(schema.sessions).all()).toHaveLength(0); }); test.each(["token", "invalid_token", "profile", "malformed", "throw"] as const)("provider failure (%s) leaves no account or session", async value => { failure = value; const result = await login(); expect(result.response.headers.get("Location")).toBe("/?auth_error=discord_unavailable"); expect(result.cookie).toBe(""); expect(db.select().from(schema.users).all()).toHaveLength(0); expect(db.select().from(schema.sessions).all()).toHaveLength(0); }); test("expired sessions are rejected at their exact expiry", async () => { const result = await login(); timestamp = db.select().from(schema.sessions).get()!.expiresAt; expect((await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } })).status).toBe(401); expect(db.select().from(schema.sessions).all()).toHaveLength(0); }); test("logout rejects cross-origin or missing origins and revokes only the current session", async () => { const first = await login(); const second = await login(); for (const headers of [new Headers({ Cookie: first.cookie }), new Headers({ Cookie: first.cookie, Origin: "https://evil.example" })]) { expect((await app.request(`${origin}/api/auth/logout`, { method: "POST", headers })).status).toBe(403); } expect(db.select().from(schema.sessions).all()).toHaveLength(2); const response = await app.request(`${origin}/api/auth/logout`, { method: "POST", headers: { Cookie: first.cookie, Origin: origin } }); expect(response.status).toBe(204); expect(response.headers.get("Set-Cookie")).toContain("Max-Age=0"); expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401); expect((await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } })).status).toBe(200); expect((await app.request(`${origin}/api/auth/logout`)).status).toBe(404); }); test("foreign keys cascade session deletion and duplicate Discord IDs are rejected", async () => { await login(); const user = db.select().from(schema.users).get()!; expect(() => db.insert(schema.users).values({ ...user, id: "different" }).run()).toThrow(); db.delete(schema.users).where(eq(schema.users.id, user.id)).run(); expect(db.select().from(schema.sessions).all()).toHaveLength(0); }); test("HTTPS cookies are Secure and use host-only names", async () => { const secureConfig = { ...config, origin: "https://habits.example" }; const secureApp = createApi(db, secureConfig, request, () => timestamp); const flow = await begin("UTC", secureApp, secureConfig.origin); const response = await secureApp.request(`${secureConfig.origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } }); expect(response.headers.getSetCookie().find(value => value.startsWith("__Host-minabot_session="))).toContain("Secure"); expect(flow.response.headers.get("Set-Cookie")).toContain("Secure"); }); test("missing credentials fail gracefully and unsafe configured origins fail fast", async () => { const unconfigured = createApi(db, { ...config, clientSecret: "" }, request); const response = await unconfigured.request(`${origin}/api/auth/discord`); expect(response.headers.get("Location")).toBe("/?auth_error=not_configured"); expect(calls).toHaveLength(0); expect(() => readAuthConfig({ APP_ORIGIN: "http://habits.example" })).toThrow(); expect(() => readAuthConfig({ APP_ORIGIN: "https://habits.example/path" })).toThrow(); }); });