Files
SnapOtter/tests/integration/platform/oidc-callback-coverage.test.ts
T
SnapOtterandGitHub 301e6eb01a test: coverage campaign and mutation testing across five packages (#628)
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
2026-07-24 17:36:57 +08:00

417 lines
17 KiB
TypeScript

/**
* Branch-coverage completion for the OIDC callback route
* (apps/api/src/plugins/oidc.ts).
*
* The sibling files leave a handful of callback branches uncovered because
* they need a controlled ID-token payload (no real signed JWT from a live
* JWKS) or a cold discovery cache at callback time:
*
* - getOrDiscoverConfig secure branch + discovery failure DURING the
* callback (oidc.ts:225-229), not just during login.
* - deriveUsername fallbacks (oidc.ts:62-91): configured claim, the
* preferred_username fallback when a *custom* claim key is configured but
* absent, the display-name fallback, and the bare-subject fallback.
* - resolveExternalUser "denied" outcomes surfaced by the callback
* (oidc.ts:282-288): user_not_authorized and user_limit_reached.
* - claims() returning no ID-token claims (oidc.ts:253-258).
* - the optional-MFA-plugin catch (oidc.ts:325-327): the MFA policy lookup
* throwing must fail *open* (login proceeds) because MFA is an optional
* enterprise plugin.
*
* Like oidc-mfa-callback.test.ts, the cryptographic token exchange is mocked
* at the `openid-client` boundary (only `authorizationCodeGrant`; discovery,
* PKCE, and URL building stay real) so the REAL callback route, REAL signed
* state cookie, REAL resolver, and REAL session creation run end to end.
*
* This is a new sibling rather than an extension of oidc-auth.test.ts on
* purpose: that file drives real login handshakes whose token exchange is
* expected to FAIL against a mock provider, so globally replacing
* `authorizationCodeGrant` there would break its existing token-exchange
* assertions.
*/
import { createServer, type Server } from "node:http";
import { sign } from "@fastify/cookie";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const authorizationCodeGrantMock = vi.hoisted(() => vi.fn());
vi.mock("openid-client", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, authorizationCodeGrant: authorizationCodeGrantMock };
});
// trackEvent is mocked so OIDC failure analytics can be asserted without a
// baked PostHog client; every other analytics export stays real.
const trackEventSpy = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("../../../apps/api/src/lib/analytics.js", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, trackEvent: trackEventSpy };
});
vi.resetModules();
const { env } = await import("../../../apps/api/src/config.js");
const { db, schema } = await import("../../../apps/api/src/db/index.js");
const { sanitizeUsername } = await import("../../../apps/api/src/lib/external-auth-resolver.js");
const mfaModule = await import("../../../apps/api/src/plugins/mfa.js");
const { buildTestApp } = await import("../test-server.js");
import type { TestApp } from "../test-server.js";
// Sign our own oidc-state cookie with the exact secret buildTestApp() gives
// @fastify/cookie, so callback branches are reachable without first driving a
// real login (which would warm the module-level discovery cache).
const TEST_COOKIE_SECRET = "test-cookie-secret";
function signState(state: string): string {
return sign(JSON.stringify({ state, nonce: "n", codeVerifier: "v" }), TEST_COOKIE_SECRET);
}
async function findUserByExternalId(externalId: string) {
const [row] = await db
.select()
.from(schema.users)
.where(and(eq(schema.users.externalId, externalId), eq(schema.users.authProvider, "oidc")))
.limit(1);
return row;
}
// =====================================================================
// CALLBACK-TIME DISCOVERY FAILURE (oidc.ts:225-229)
//
// MUST be the first describe: getOrDiscoverConfig() caches the resolved
// config in a module-level variable for 24h. This test needs a COLD cache so
// the callback's own getOrDiscoverConfig() (line 225) is the call that fails.
// A failed discovery never populates the cache, so later describes can still
// discover successfully against the live mock provider.
// =====================================================================
describe("OIDC callback discovery failure (cold cache)", () => {
let oidcApp: TestApp;
let deadServer: Server;
let deadPort: number;
const origOidcEnabled = env.OIDC_ENABLED;
const origExternalUrl = env.EXTERNAL_URL;
const origIssuerUrl = env.OIDC_ISSUER_URL;
const origClientId = env.OIDC_CLIENT_ID;
const origClientSecret = env.OIDC_CLIENT_SECRET;
beforeAll(async () => {
deadServer = createServer((_req, res) => {
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve) => {
deadServer.listen(0, "127.0.0.1", () => {
const addr = deadServer.address();
deadPort = typeof addr === "object" && addr ? addr.port : 0;
resolve();
});
});
(env as any).OIDC_ENABLED = true;
// https EXTERNAL_URL makes isSecure() true, so getOrDiscoverConfig() takes
// the `execute: undefined` (secure) arm of the discovery ternary. Discovery
// still fails because the issuer 404s the discovery document.
(env as any).EXTERNAL_URL = "https://localhost:9999";
(env as any).OIDC_ISSUER_URL = `http://localhost:${deadPort}`;
(env as any).OIDC_CLIENT_ID = "test-client-id";
(env as any).OIDC_CLIENT_SECRET = "test-client-secret";
oidcApp = await buildTestApp();
}, 30_000);
afterAll(async () => {
(env as any).OIDC_ENABLED = origOidcEnabled;
(env as any).EXTERNAL_URL = origExternalUrl;
(env as any).OIDC_ISSUER_URL = origIssuerUrl;
(env as any).OIDC_CLIENT_ID = origClientId;
(env as any).OIDC_CLIENT_SECRET = origClientSecret;
await oidcApp.cleanup();
await new Promise<void>((resolve) => deadServer.close(() => resolve()));
}, 10_000);
it("redirects to oidc_provider_unreachable when discovery fails at callback time", async () => {
// Signed cookie whose state matches the query, so the callback passes the
// cookie + state guards and reaches getOrDiscoverConfig() at line 225.
const cookieValue = signState("cold-state");
const res = await oidcApp.app.inject({
method: "GET",
url: "/api/auth/oidc/callback?code=abc&state=cold-state",
cookies: { "oidc-state": cookieValue },
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/login?error=oidc_provider_unreachable");
// authorizationCodeGrant is never reached because discovery failed first.
expect(authorizationCodeGrantMock).not.toHaveBeenCalled();
});
});
// =====================================================================
// CLAIMS / USERNAME-DERIVATION / RESOLVER-DENIED (live mock provider)
// =====================================================================
describe("OIDC callback claim handling and resolver outcomes", () => {
let oidcApp: TestApp;
let mockServer: Server;
let mockPort: number;
const origOidcEnabled = env.OIDC_ENABLED;
const origExternalUrl = env.EXTERNAL_URL;
const origIssuerUrl = env.OIDC_ISSUER_URL;
const origClientId = env.OIDC_CLIENT_ID;
const origClientSecret = env.OIDC_CLIENT_SECRET;
const origAutoCreate = env.OIDC_AUTO_CREATE_USERS;
const origUsernameClaim = env.OIDC_USERNAME_CLAIM;
const origMaxUsers = env.MAX_USERS;
// Drive the callback with a fully-controlled ID-token payload. `claims` is
// whatever authorizationCodeGrant.claims() should return; passing `null`
// exercises the no-claims branch.
async function callbackWithClaims(
claims: Record<string, unknown> | null,
idToken: string | null = "fake-id-token",
) {
authorizationCodeGrantMock.mockResolvedValueOnce({
claims: () => claims ?? undefined,
id_token: idToken,
});
const state = `st-${Math.random().toString(36).slice(2, 10)}`;
const cookieValue = signState(state);
return oidcApp.app.inject({
method: "GET",
url: `/api/auth/oidc/callback?code=code-abc&state=${state}`,
cookies: { "oidc-state": cookieValue },
});
}
beforeAll(async () => {
mockServer = createServer((req, res) => {
if (req.url === "/.well-known/openid-configuration") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
issuer: `http://localhost:${mockPort}`,
authorization_endpoint: `http://localhost:${mockPort}/authorize`,
token_endpoint: `http://localhost:${mockPort}/token`,
jwks_uri: `http://localhost:${mockPort}/jwks`,
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
code_challenge_methods_supported: ["S256"],
}),
);
return;
}
if (req.url === "/jwks") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ keys: [] }));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve) => {
mockServer.listen(0, "127.0.0.1", () => {
const addr = mockServer.address();
mockPort = typeof addr === "object" && addr ? addr.port : 0;
resolve();
});
});
(env as any).OIDC_ENABLED = true;
// http EXTERNAL_URL -> isSecure() false -> discovery is allowed against the
// insecure mock issuer (allowInsecureRequests arm).
(env as any).EXTERNAL_URL = "http://localhost:9999";
(env as any).OIDC_ISSUER_URL = `http://localhost:${mockPort}`;
(env as any).OIDC_CLIENT_ID = "test-client-id";
(env as any).OIDC_CLIENT_SECRET = "test-client-secret";
(env as any).OIDC_AUTO_CREATE_USERS = true;
(env as any).OIDC_USERNAME_CLAIM = "preferred_username";
(env as any).MAX_USERS = 0;
oidcApp = await buildTestApp();
}, 30_000);
afterEach(() => {
authorizationCodeGrantMock.mockReset();
trackEventSpy.mockClear();
// Reset the knobs individual tests tweak back to the describe defaults.
(env as any).OIDC_AUTO_CREATE_USERS = true;
(env as any).OIDC_USERNAME_CLAIM = "preferred_username";
(env as any).MAX_USERS = 0;
});
afterAll(async () => {
(env as any).OIDC_ENABLED = origOidcEnabled;
(env as any).EXTERNAL_URL = origExternalUrl;
(env as any).OIDC_ISSUER_URL = origIssuerUrl;
(env as any).OIDC_CLIENT_ID = origClientId;
(env as any).OIDC_CLIENT_SECRET = origClientSecret;
(env as any).OIDC_AUTO_CREATE_USERS = origAutoCreate;
(env as any).OIDC_USERNAME_CLAIM = origUsernameClaim;
(env as any).MAX_USERS = origMaxUsers;
await oidcApp.cleanup();
await new Promise<void>((resolve) => mockServer.close(() => resolve()));
}, 10_000);
it("fails with oidc_auth_failed when the token response carries no ID-token claims", async () => {
const res = await callbackWithClaims(null);
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/login?error=oidc_auth_failed");
// The no-claims path records the failed attempt, same as the password path.
expect(trackEventSpy).toHaveBeenCalledWith("auth_login_failed", { method: "oidc" });
const setCookie = res.headers["set-cookie"];
expect(String(setCookie ?? "")).not.toContain("snapotter-session=");
});
it("derives the username from the configured claim (preferred_username)", async () => {
const sub = `sub-pref-${Math.random().toString(36).slice(2, 10)}`;
const raw = `PrefUser.${Math.random().toString(36).slice(2, 8)}`;
// email is present too, but the configured preferred_username claim wins.
const res = await callbackWithClaims({
sub,
preferred_username: raw,
email: `${sub}@example.com`,
email_verified: true,
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/");
const user = await findUserByExternalId(sub);
expect(user).toBeDefined();
expect(user?.username).toBe(sanitizeUsername(raw));
});
it("falls back to preferred_username when a custom username claim is configured but absent", async () => {
// Configure a custom claim key the token does NOT contain, so branch 1
// (configured claim) is skipped and branch 2 (preferred_username) runs.
(env as any).OIDC_USERNAME_CLAIM = "custom_login";
const sub = `sub-custabsent-${Math.random().toString(36).slice(2, 10)}`;
const raw = `CarolPref-${Math.random().toString(36).slice(2, 8)}`;
const res = await callbackWithClaims({
sub,
preferred_username: raw,
// no custom_login claim on purpose
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/");
const user = await findUserByExternalId(sub);
expect(user?.username).toBe(sanitizeUsername(raw));
});
it("uses the custom username claim when it is present", async () => {
(env as any).OIDC_USERNAME_CLAIM = "custom_login";
const sub = `sub-custpresent-${Math.random().toString(36).slice(2, 10)}`;
const raw = `BobCustom-${Math.random().toString(36).slice(2, 8)}`;
const res = await callbackWithClaims({
sub,
custom_login: raw,
preferred_username: "should-be-ignored",
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/");
const user = await findUserByExternalId(sub);
expect(user?.username).toBe(sanitizeUsername(raw));
});
it("falls back to the display name when no username or email-with-@ claim is present", async () => {
const sub = `sub-name-${Math.random().toString(36).slice(2, 10)}`;
const raw = `Eve Adams ${Math.random().toString(36).slice(2, 6)}`;
const res = await callbackWithClaims({
sub,
name: raw,
// no preferred_username, no email
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/");
const user = await findUserByExternalId(sub);
expect(user?.username).toBe(sanitizeUsername(raw));
});
it("falls back to the subject when the token carries only a sub claim", async () => {
const sub = `sub-only-${Math.random().toString(36).slice(2, 12)}`;
const res = await callbackWithClaims({ sub });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/");
const user = await findUserByExternalId(sub);
expect(user?.username).toBe(sanitizeUsername(sub));
});
it("redirects to oidc_user_not_authorized when auto-create is off and the user is unknown", async () => {
(env as any).OIDC_AUTO_CREATE_USERS = false;
const sub = `sub-denied-${Math.random().toString(36).slice(2, 10)}`;
const res = await callbackWithClaims({
sub,
preferred_username: `nope-${Math.random().toString(36).slice(2, 8)}`,
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/login?error=oidc_user_not_authorized");
expect(trackEventSpy).toHaveBeenCalledWith("auth_login_failed", { method: "oidc" });
// No account was created and no session cookie was set.
expect(await findUserByExternalId(sub)).toBeUndefined();
const setCookie = res.headers["set-cookie"];
expect(String(setCookie ?? "")).not.toContain("snapotter-session=");
});
it("redirects to oidc_user_limit_reached when auto-create hits the user cap", async () => {
// The seeded fork already has at least the admin account, so a cap of 1
// is already met and any auto-create is refused with user_limit_reached.
(env as any).OIDC_AUTO_CREATE_USERS = true;
(env as any).MAX_USERS = 1;
const sub = `sub-limit-${Math.random().toString(36).slice(2, 10)}`;
const res = await callbackWithClaims({
sub,
preferred_username: `capped-${Math.random().toString(36).slice(2, 8)}`,
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/login?error=oidc_user_limit_reached");
expect(trackEventSpy).toHaveBeenCalledWith("auth_login_failed", { method: "oidc" });
expect(await findUserByExternalId(sub)).toBeUndefined();
});
it("logs the user in when the MFA policy lookup throws (optional plugin fails open)", async () => {
// The dynamic import("./mfa.js") in the callback resolves to this same
// module instance, so spying getMfaPolicy to reject drives the callback's
// MFA try/catch (oidc.ts:325-327). A thrown policy lookup must fail OPEN:
// MFA is an optional enterprise plugin, so login proceeds and a session is
// created rather than the user being blocked.
const spy = vi
.spyOn(mfaModule, "getMfaPolicy")
.mockRejectedValue(new Error("simulated MFA policy lookup failure"));
try {
const sub = `sub-mfathrow-${Math.random().toString(36).slice(2, 10)}`;
const res = await callbackWithClaims({
sub,
preferred_username: `mfaopen-${Math.random().toString(36).slice(2, 8)}`,
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/");
const setCookie = res.headers["set-cookie"];
expect(String(setCookie ?? "")).toContain("snapotter-session=");
// A real session row was written and the success analytics event fired.
const user = await findUserByExternalId(sub);
expect(user).toBeDefined();
const [session] = await db
.select()
.from(schema.sessions)
.where(eq(schema.sessions.userId, user?.id ?? ""))
.limit(1);
expect(session).toBeDefined();
expect(trackEventSpy).toHaveBeenCalledWith("auth_login", { method: "oidc" });
} finally {
spy.mockRestore();
}
});
});