mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
/**
|
|
* SAML SSO authentication integration tests.
|
|
*
|
|
* saml.ts (@node-saml) had effectively zero coverage. The SAML crypto boundary
|
|
* is mocked so we can drive the SnapOtter-specific logic that actually matters:
|
|
* SP metadata, the login redirect, and the ACS callback's provisioning,
|
|
* session, denial, and MFA branches. Follows the enterprise-gated integration
|
|
* pattern: reset modules, doMock the enterprise gate + @node-saml + mfa, then
|
|
* import buildTestApp so it registers the (licensed) SAML routes.
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { env } from "../../../apps/api/src/config.js";
|
|
import { db, schema } from "../../../apps/api/src/db/index.js";
|
|
import { buildTestApp, type TestApp } from "../test-server.js";
|
|
|
|
// Hoisted so the vi.mock factories (which vitest hoists above the imports) can
|
|
// close over them. saml.ts imports @node-saml STATICALLY, so it must be
|
|
// vi.mock, not vi.doMock, to be intercepted.
|
|
const samlMock = vi.hoisted(() => ({
|
|
getAuthorizeUrlAsync: vi.fn(),
|
|
validatePostResponseAsync: vi.fn(),
|
|
generateServiceProviderMetadata: vi.fn(),
|
|
}));
|
|
const mfaOutcomeMock = vi.hoisted(() => vi.fn(() => "proceed"));
|
|
// A controllable handle for getMfaPolicy so a single test can make the MFA
|
|
// policy lookup reject and drive saml.ts's `catch { /* MFA plugin not loaded */ }`
|
|
// branch (the whole MFA block is best-effort: a policy-lookup failure must fall
|
|
// back to "proceed", not fail the login).
|
|
const getMfaPolicyMock = vi.hoisted(() => vi.fn().mockResolvedValue({}));
|
|
|
|
vi.mock("@node-saml/node-saml", () => ({
|
|
ValidateInResponseTo: { ifPresent: "ifPresent", always: "always", never: "never" },
|
|
SAML: class {
|
|
getAuthorizeUrlAsync = (...a: unknown[]) => samlMock.getAuthorizeUrlAsync(...a);
|
|
validatePostResponseAsync = (...a: unknown[]) => samlMock.validatePostResponseAsync(...a);
|
|
generateServiceProviderMetadata = (...a: unknown[]) =>
|
|
samlMock.generateServiceProviderMetadata(...a);
|
|
},
|
|
}));
|
|
vi.mock("../../../apps/api/src/plugins/mfa.js", async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
return {
|
|
...actual,
|
|
getMfaPolicy: (...a: unknown[]) => getMfaPolicyMock(...a),
|
|
resolveExternalLoginMfaOutcome: (...a: unknown[]) => mfaOutcomeMock(...a),
|
|
};
|
|
});
|
|
// SAML is enterprise-gated; license the saml_sso feature so the routes register.
|
|
vi.mock("@snapotter/enterprise", () => ({
|
|
isFeatureEnabled: (f: string) => f === "saml_sso",
|
|
getActiveLicense: () => ({
|
|
org: "test-org",
|
|
plan: "enterprise",
|
|
features: ["saml_sso"],
|
|
seats: 100,
|
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
|
issuedAt: new Date().toISOString(),
|
|
}),
|
|
initEnterprise: vi.fn(),
|
|
loadS3Storage: vi.fn(),
|
|
ENTERPRISE_FEATURES: ["saml_sso"],
|
|
PLAN_FEATURES: { team: [], enterprise: ["saml_sso"] },
|
|
}));
|
|
|
|
let testApp: TestApp;
|
|
|
|
const saved: Record<string, unknown> = {};
|
|
const SAML_ENV = {
|
|
SAML_ENABLED: true,
|
|
EXTERNAL_URL: "http://localhost:9999",
|
|
SAML_IDP_SSO_URL: "http://localhost:0/sso",
|
|
SAML_IDP_CERTIFICATE: "MIIC-test-certificate",
|
|
SAML_EMAIL_ATTRIBUTE: "email",
|
|
SAML_AUTO_CREATE_USERS: true,
|
|
SAML_AUTO_LINK_USERS: true,
|
|
SAML_DEFAULT_ROLE: "user",
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
for (const [k, v] of Object.entries(SAML_ENV)) {
|
|
saved[k] = (env as Record<string, unknown>)[k];
|
|
(env as Record<string, unknown>)[k] = v;
|
|
}
|
|
testApp = await buildTestApp();
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
for (const [k, v] of Object.entries(saved)) {
|
|
(env as Record<string, unknown>)[k] = v;
|
|
}
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
|
|
function postCallback() {
|
|
return testApp.app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/saml/callback",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
payload: "SAMLResponse=stub",
|
|
});
|
|
}
|
|
|
|
describe("SAML metadata", () => {
|
|
it("serves SP metadata XML", async () => {
|
|
samlMock.generateServiceProviderMetadata.mockReturnValue(
|
|
'<?xml version="1.0"?><EntityDescriptor entityID="sp"/>',
|
|
);
|
|
const res = await testApp.app.inject({ method: "GET", url: "/api/auth/saml/metadata" });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toContain("xml");
|
|
expect(res.body).toContain("EntityDescriptor");
|
|
});
|
|
});
|
|
|
|
describe("SAML login redirect", () => {
|
|
it("redirects to the IdP authorize URL", async () => {
|
|
samlMock.getAuthorizeUrlAsync.mockResolvedValue("https://idp.example/sso?SAMLRequest=abc");
|
|
const res = await testApp.app.inject({ method: "GET", url: "/api/auth/saml/login" });
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("https://idp.example/sso?SAMLRequest=abc");
|
|
});
|
|
|
|
it("redirects to /login on IdP redirect failure", async () => {
|
|
samlMock.getAuthorizeUrlAsync.mockRejectedValue(new Error("no entryPoint"));
|
|
const res = await testApp.app.inject({ method: "GET", url: "/api/auth/saml/login" });
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("/login?error=saml_auth_failed");
|
|
});
|
|
});
|
|
|
|
describe("SAML callback", () => {
|
|
it("rejects an assertion that fails validation", async () => {
|
|
samlMock.validatePostResponseAsync.mockRejectedValue(new Error("invalid signature"));
|
|
const res = await postCallback();
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("/login?error=saml_auth_failed");
|
|
});
|
|
|
|
it("rejects an assertion with no nameID", async () => {
|
|
samlMock.validatePostResponseAsync.mockResolvedValue({ profile: { email: "x@example.com" } });
|
|
const res = await postCallback();
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("/login?error=saml_auth_failed");
|
|
});
|
|
|
|
it("provisions a user, creates a session, and sets the cookie on success", async () => {
|
|
const email = `alice-${randomUUID().slice(0, 8)}@example.com`;
|
|
samlMock.validatePostResponseAsync.mockResolvedValue({ profile: { nameID: email, email } });
|
|
mfaOutcomeMock.mockReturnValue("proceed");
|
|
|
|
const res = await postCallback();
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("/");
|
|
|
|
const setCookie = res.headers["set-cookie"];
|
|
const cookieStr = Array.isArray(setCookie) ? setCookie.join("; ") : setCookie || "";
|
|
expect(cookieStr).toContain("snapotter-session=");
|
|
expect(cookieStr.toLowerCase()).toContain("httponly");
|
|
|
|
// The user was auto-created with the SAML provider and a session exists.
|
|
const [user] = await db.select().from(schema.users).where(eq(schema.users.externalId, email));
|
|
expect(user).toBeDefined();
|
|
expect(user?.authProvider).toBe("saml");
|
|
const sessions = await db
|
|
.select()
|
|
.from(schema.sessions)
|
|
.where(eq(schema.sessions.userId, user?.id as string));
|
|
expect(sessions.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("derives the username from the configured username attribute", async () => {
|
|
(env as Record<string, unknown>).SAML_USERNAME_ATTRIBUTE = "uid";
|
|
try {
|
|
const uid = `custom${randomUUID().slice(0, 6)}`;
|
|
samlMock.validatePostResponseAsync.mockResolvedValue({
|
|
profile: { nameID: `id-${uid}`, email: "different@example.com", uid },
|
|
});
|
|
mfaOutcomeMock.mockReturnValue("proceed");
|
|
const res = await postCallback();
|
|
expect(res.statusCode).toBe(302);
|
|
// Username comes from the uid attribute, not the email local-part.
|
|
const [user] = await db
|
|
.select()
|
|
.from(schema.users)
|
|
.where(eq(schema.users.externalId, `id-${uid}`));
|
|
expect(user?.username).toContain(uid.toLowerCase());
|
|
} finally {
|
|
(env as Record<string, unknown>).SAML_USERNAME_ATTRIBUTE = undefined;
|
|
}
|
|
});
|
|
|
|
it("denies an unknown user when auto-create is off", async () => {
|
|
(env as Record<string, unknown>).SAML_AUTO_CREATE_USERS = false;
|
|
try {
|
|
const email = `nobody-${randomUUID().slice(0, 8)}@example.com`;
|
|
samlMock.validatePostResponseAsync.mockResolvedValue({ profile: { nameID: email, email } });
|
|
const res = await postCallback();
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("/login?error=saml_user_not_authorized");
|
|
} finally {
|
|
(env as Record<string, unknown>).SAML_AUTO_CREATE_USERS = true;
|
|
}
|
|
});
|
|
|
|
it("issues an MFA challenge when the policy requires it", async () => {
|
|
const email = `mfa-${randomUUID().slice(0, 8)}@example.com`;
|
|
samlMock.validatePostResponseAsync.mockResolvedValue({ profile: { nameID: email, email } });
|
|
mfaOutcomeMock.mockReturnValue("challenge");
|
|
const res = await postCallback();
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toMatch(/^\/login\?mfaToken=/);
|
|
mfaOutcomeMock.mockReturnValue("proceed");
|
|
});
|
|
|
|
it("blocks login when MFA enrollment is required", async () => {
|
|
const email = `enroll-${randomUUID().slice(0, 8)}@example.com`;
|
|
samlMock.validatePostResponseAsync.mockResolvedValue({ profile: { nameID: email, email } });
|
|
mfaOutcomeMock.mockReturnValue("enrollment_required");
|
|
const res = await postCallback();
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("/login?error=mfa_enrollment_required");
|
|
mfaOutcomeMock.mockReturnValue("proceed");
|
|
});
|
|
|
|
it("redirects to a distinct error when the user limit is reached", async () => {
|
|
// Drive the REAL external-auth resolver into its user_limit_reached branch
|
|
// (autoCreate on, MAX_USERS exceeded by the accounts other tests already
|
|
// seeded) so saml.ts maps deniedReason -> `saml_user_limit_reached` rather
|
|
// than the generic `saml_user_not_authorized`. A brand-new nameID/email
|
|
// guarantees the resolver misses both the externalId match and the
|
|
// auto-link-by-email step and falls through to the auto-create limit check.
|
|
const origMaxUsers = (env as Record<string, unknown>).MAX_USERS;
|
|
(env as Record<string, unknown>).MAX_USERS = 1;
|
|
try {
|
|
const email = `overlimit-${randomUUID().slice(0, 8)}@example.com`;
|
|
samlMock.validatePostResponseAsync.mockResolvedValue({ profile: { nameID: email, email } });
|
|
mfaOutcomeMock.mockReturnValue("proceed");
|
|
|
|
const res = await postCallback();
|
|
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("/login?error=saml_user_limit_reached");
|
|
|
|
// The limit was enforced: no user was created for this assertion.
|
|
const [created] = await db
|
|
.select()
|
|
.from(schema.users)
|
|
.where(eq(schema.users.externalId, email));
|
|
expect(created).toBeUndefined();
|
|
} finally {
|
|
(env as Record<string, unknown>).MAX_USERS = origMaxUsers;
|
|
}
|
|
});
|
|
|
|
it("fails the login closed when the MFA enrollment-status read throws", async () => {
|
|
// The users read that decides whether MFA is checked is intentionally
|
|
// unguarded in saml.ts: a DB error there must fail the login, never
|
|
// silently skip MFA for an enrolled user. Spy only the totpEnabled select
|
|
// (the same shape saml.ts issues) so provisioning/session selects still hit
|
|
// the real DB. Mirrors the OIDC-callback fail-closed test.
|
|
const email = `dberr-${randomUUID().slice(0, 8)}@example.com`;
|
|
samlMock.validatePostResponseAsync.mockResolvedValue({ profile: { nameID: email, email } });
|
|
mfaOutcomeMock.mockReturnValue("proceed");
|
|
|
|
const originalSelect = db.select.bind(db);
|
|
const selectSpy = vi.spyOn(db, "select").mockImplementation((...args: unknown[]) => {
|
|
const selection = args[0] as Record<string, unknown> | undefined;
|
|
if (selection && "totpEnabled" in selection) {
|
|
throw new Error("simulated DB failure");
|
|
}
|
|
// biome-ignore lint/suspicious/noExplicitAny: passthrough to the real overloaded implementation
|
|
return (originalSelect as any)(...args);
|
|
});
|
|
|
|
try {
|
|
const res = await postCallback();
|
|
|
|
// Must NOT proceed to a session and must NOT issue an MFA challenge:
|
|
// a broken enrollment-status read means the login fails, full stop.
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("/login?error=saml_auth_failed");
|
|
const setCookie = res.headers["set-cookie"];
|
|
expect(String(setCookie ?? "")).not.toContain("snapotter-session=");
|
|
|
|
// No session row was minted for the resolved user.
|
|
const [user] = await db.select().from(schema.users).where(eq(schema.users.externalId, email));
|
|
expect(user).toBeDefined();
|
|
const sessions = await db
|
|
.select()
|
|
.from(schema.sessions)
|
|
.where(eq(schema.sessions.userId, user?.id as string));
|
|
expect(sessions.length).toBe(0);
|
|
} finally {
|
|
selectSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("still logs in when the MFA policy lookup fails (best-effort MFA block)", async () => {
|
|
// saml.ts wraps the getMfaPolicy/resolveExternalLoginMfaOutcome lookup in a
|
|
// try/catch that swallows failures and leaves the outcome at "proceed" (the
|
|
// MFA plugin may not be loaded). Make the policy lookup reject once and
|
|
// confirm the login still completes: session created, redirect to `/`.
|
|
const email = `mfaerr-${randomUUID().slice(0, 8)}@example.com`;
|
|
samlMock.validatePostResponseAsync.mockResolvedValue({ profile: { nameID: email, email } });
|
|
getMfaPolicyMock.mockRejectedValueOnce(new Error("mfa policy store unavailable"));
|
|
|
|
try {
|
|
const res = await postCallback();
|
|
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe("/");
|
|
const setCookie = res.headers["set-cookie"];
|
|
const cookieStr = Array.isArray(setCookie) ? setCookie.join("; ") : setCookie || "";
|
|
expect(cookieStr).toContain("snapotter-session=");
|
|
|
|
// A real session was still created despite the MFA lookup throwing.
|
|
const [user] = await db.select().from(schema.users).where(eq(schema.users.externalId, email));
|
|
expect(user).toBeDefined();
|
|
const sessions = await db
|
|
.select()
|
|
.from(schema.sessions)
|
|
.where(eq(schema.sessions.userId, user?.id as string));
|
|
expect(sessions.length).toBeGreaterThan(0);
|
|
} finally {
|
|
getMfaPolicyMock.mockResolvedValue({});
|
|
}
|
|
});
|
|
});
|