Files
minabot/src/auth/auth.test.ts

208 lines
12 KiB
TypeScript

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<typeof drizzle<typeof schema>>;
let app: ReturnType<typeof createApi>;
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("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();
});
});