208 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|