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.
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
/**
|
|
* Mutation-focused unit tests for apps/api/src/lib/external-auth-resolver.ts.
|
|
*
|
|
* The existing external-auth-resolver.test.ts covers sanitizeUsername and
|
|
* findUniqueUsername well, plus one auto-create-denied case. This file drives
|
|
* resolveExternalUser through every decision branch so the survived / no-cov
|
|
* mutants there die:
|
|
* - match-by-externalId (matched) + the disabled-role guard on that path
|
|
* - the "email changed" UPDATE (issued only when email differs)
|
|
* - auto-link-by-email (linked): the autoLink && email && emailVerified gate,
|
|
* the disabled guard, and the externalId/authProvider UPDATE it writes
|
|
* - auto-create (created): the MAX_USERS >= limit guard, the "Default" team
|
|
* lookup with its fallback id, and the exact INSERT values
|
|
* - the terminal denied path (user_not_authorized) with sanitized externalId
|
|
* Every canned DB row and the audit events are asserted so operator/string/
|
|
* boolean mutants cannot survive undetected.
|
|
*
|
|
* Container-free: db, config, and audit are mocked; isDisabledRole is the real
|
|
* function (it is pure).
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// ── Hoisted mock state ──────────────────────────────────────────────────
|
|
|
|
const state = vi.hoisted(() => ({
|
|
// Rows returned by each db.select() chain, in call order.
|
|
selectRows: [] as unknown[][],
|
|
selectIdx: 0,
|
|
updates: [] as Record<string, unknown>[],
|
|
inserts: [] as Record<string, unknown>[],
|
|
maxUsers: 0,
|
|
auditCalls: [] as { event: string; details: Record<string, unknown> }[],
|
|
}));
|
|
|
|
vi.mock("../../../apps/api/src/config.js", () => ({
|
|
env: {
|
|
get MAX_USERS() {
|
|
return state.maxUsers;
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../../apps/api/src/lib/audit.js", () => ({
|
|
auditLog: (_logger: unknown, event: string, details: Record<string, unknown> = {}) => {
|
|
state.auditCalls.push({ event, details });
|
|
return Promise.resolve();
|
|
},
|
|
// sanitizeAuditInput is used on the denied path; keep the real strip behavior
|
|
// so we can assert the value that reaches the audit call.
|
|
sanitizeAuditInput: (raw: string) => raw.replace(/[<>&"'\r\n\0]/g, "").slice(0, 200) || "(empty)",
|
|
}));
|
|
|
|
// A select chain that is both awaitable and chainable through
|
|
// .from()/.where()/.limit(). resolveExternalUser awaits the terminal at three
|
|
// different depths (.limit(1) for id/email matches, .from() for the COUNT, and
|
|
// .where() for the username/team lookups), so every level is a thenable. Only
|
|
// the terminal await consumes one canned row set (settle() increments the index
|
|
// lazily), which keeps call-order pulls correct across the mixed shapes.
|
|
function makeSelectChain() {
|
|
const settle = () => {
|
|
const value = state.selectRows[state.selectIdx++] ?? [];
|
|
return Promise.resolve(value);
|
|
};
|
|
const node: Record<string, unknown> = {
|
|
from: () => node,
|
|
where: () => node,
|
|
limit: () => settle(),
|
|
// biome-ignore lint/suspicious/noThenProperty: intentional thenable mocking an awaitable Drizzle query builder
|
|
then: (onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) =>
|
|
settle().then(onFulfilled, onRejected),
|
|
};
|
|
return node;
|
|
}
|
|
|
|
vi.mock("../../../apps/api/src/db/index.js", () => ({
|
|
db: {
|
|
select: () => makeSelectChain(),
|
|
update: () => ({
|
|
set: (v: Record<string, unknown>) => {
|
|
state.updates.push(v);
|
|
return { where: () => Promise.resolve() };
|
|
},
|
|
}),
|
|
insert: () => ({
|
|
values: (v: Record<string, unknown>) => {
|
|
state.inserts.push(v);
|
|
return Promise.resolve();
|
|
},
|
|
}),
|
|
},
|
|
schema: {
|
|
users: {
|
|
id: "id",
|
|
username: "username",
|
|
email: "email",
|
|
role: "role",
|
|
team: "team",
|
|
externalId: "external_id",
|
|
authProvider: "auth_provider",
|
|
},
|
|
teams: { id: "id", name: "name" },
|
|
},
|
|
}));
|
|
|
|
import { resolveExternalUser } from "../../../apps/api/src/lib/external-auth-resolver.js";
|
|
|
|
function makeLogger() {
|
|
return { info: vi.fn(), warn: vi.fn() };
|
|
}
|
|
|
|
function baseParams(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
provider: "oidc",
|
|
externalId: "ext-1",
|
|
email: undefined as string | undefined,
|
|
emailVerified: undefined as boolean | undefined,
|
|
username: "alice",
|
|
autoCreate: false,
|
|
autoLink: false,
|
|
defaultRole: "user",
|
|
logger: makeLogger() as never,
|
|
ip: "10.0.0.1",
|
|
requestId: "req-1",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const dbUser = (over: Record<string, unknown> = {}) => ({
|
|
id: "u-existing",
|
|
username: "existing",
|
|
email: "old@example.com",
|
|
role: "editor",
|
|
team: "team-a",
|
|
externalId: "ext-1",
|
|
authProvider: "oidc",
|
|
...over,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
state.selectRows = [];
|
|
state.selectIdx = 0;
|
|
state.updates = [];
|
|
state.inserts = [];
|
|
state.maxUsers = 0;
|
|
state.auditCalls = [];
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ── 1. Match by externalId ───────────────────────────────────────────────
|
|
|
|
describe("resolveExternalUser: match by externalId", () => {
|
|
it("returns the matched user mapped to id/username/role/team", async () => {
|
|
state.selectRows = [[dbUser()]];
|
|
const result = await resolveExternalUser(baseParams());
|
|
expect(result).toEqual({
|
|
user: { id: "u-existing", username: "existing", role: "editor", team: "team-a" },
|
|
action: "matched",
|
|
});
|
|
});
|
|
|
|
it("denies a matched user whose role is disabled and audits user_disabled", async () => {
|
|
state.selectRows = [[dbUser({ role: "disabled" })]];
|
|
const result = await resolveExternalUser(baseParams());
|
|
expect(result).toEqual({ user: null, action: "denied", deniedReason: "user_disabled" });
|
|
expect(state.auditCalls[0]).toEqual({
|
|
event: "OIDC_LOGIN_FAILED",
|
|
details: { reason: "user_disabled", userId: "u-existing" },
|
|
});
|
|
});
|
|
|
|
it("does NOT issue an email UPDATE when the incoming email equals the stored email", async () => {
|
|
state.selectRows = [[dbUser({ email: "same@example.com" })]];
|
|
await resolveExternalUser(baseParams({ email: "same@example.com" }));
|
|
expect(state.updates).toHaveLength(0);
|
|
});
|
|
|
|
it("issues an email UPDATE only when the incoming email differs", async () => {
|
|
state.selectRows = [[dbUser({ email: "old@example.com" })]];
|
|
const result = await resolveExternalUser(baseParams({ email: "new@example.com" }));
|
|
expect(state.updates).toHaveLength(1);
|
|
expect(state.updates[0].email).toBe("new@example.com");
|
|
expect(result.action).toBe("matched");
|
|
});
|
|
|
|
it("does not issue an email UPDATE when no email is supplied", async () => {
|
|
state.selectRows = [[dbUser({ email: "old@example.com" })]];
|
|
await resolveExternalUser(baseParams({ email: undefined }));
|
|
expect(state.updates).toHaveLength(0);
|
|
});
|
|
|
|
it("uppercases the provider for the audit event prefix (saml -> SAML)", async () => {
|
|
state.selectRows = [[dbUser({ role: "disabled" })]];
|
|
await resolveExternalUser(baseParams({ provider: "saml" }));
|
|
expect(state.auditCalls[0].event).toBe("SAML_LOGIN_FAILED");
|
|
});
|
|
});
|
|
|
|
// ── 2. Auto-link by email ────────────────────────────────────────────────
|
|
|
|
describe("resolveExternalUser: auto-link by email", () => {
|
|
it("links a verified email match, writes externalId/authProvider, audits USER_LINKED", async () => {
|
|
state.selectRows = [
|
|
[], // no externalId match
|
|
[dbUser({ id: "u-link", username: "linkme", externalId: null, authProvider: null })],
|
|
];
|
|
const result = await resolveExternalUser(
|
|
baseParams({ autoLink: true, email: "known@example.com", emailVerified: true }),
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
user: { id: "u-link", username: "linkme", role: "editor", team: "team-a" },
|
|
action: "linked",
|
|
});
|
|
expect(state.updates).toHaveLength(1);
|
|
expect(state.updates[0].externalId).toBe("ext-1");
|
|
expect(state.updates[0].authProvider).toBe("oidc");
|
|
expect(state.auditCalls.at(-1)).toEqual({
|
|
event: "OIDC_USER_LINKED",
|
|
details: { userId: "u-link", username: "linkme", email: "known@example.com" },
|
|
});
|
|
});
|
|
|
|
it("denies linking when the email-matched user is disabled", async () => {
|
|
state.selectRows = [[], [dbUser({ id: "u-dis", role: "disabled:pending" })]];
|
|
const result = await resolveExternalUser(
|
|
baseParams({ autoLink: true, email: "known@example.com", emailVerified: true }),
|
|
);
|
|
expect(result).toEqual({ user: null, action: "denied", deniedReason: "user_disabled" });
|
|
expect(state.updates).toHaveLength(0);
|
|
});
|
|
|
|
it("does not auto-link when emailVerified is false (falls through to denied)", async () => {
|
|
state.selectRows = [[]]; // only the extId query runs; email branch is gated off
|
|
const result = await resolveExternalUser(
|
|
baseParams({ autoLink: true, email: "known@example.com", emailVerified: false }),
|
|
);
|
|
expect(result.action).toBe("denied");
|
|
expect(result.deniedReason).toBe("user_not_authorized");
|
|
});
|
|
|
|
it("does not auto-link when autoLink is false", async () => {
|
|
state.selectRows = [[]];
|
|
const result = await resolveExternalUser(
|
|
baseParams({ autoLink: false, email: "known@example.com", emailVerified: true }),
|
|
);
|
|
expect(result.deniedReason).toBe("user_not_authorized");
|
|
});
|
|
|
|
it("does not auto-link when no email is provided", async () => {
|
|
state.selectRows = [[]];
|
|
const result = await resolveExternalUser(
|
|
baseParams({ autoLink: true, email: undefined, emailVerified: true }),
|
|
);
|
|
expect(result.deniedReason).toBe("user_not_authorized");
|
|
});
|
|
});
|
|
|
|
// ── 3. Auto-create ───────────────────────────────────────────────────────
|
|
|
|
describe("resolveExternalUser: auto-create", () => {
|
|
it("creates a new user with the default role/team and audits USER_CREATED", async () => {
|
|
state.selectRows = [
|
|
[], // extId miss
|
|
[], // findUniqueUsername: base free
|
|
[{ id: "team-default" }], // Default team lookup
|
|
];
|
|
const result = await resolveExternalUser(
|
|
baseParams({ autoCreate: true, email: "new@example.com", username: "alice" }),
|
|
);
|
|
|
|
expect(result.action).toBe("created");
|
|
expect(result.user).toEqual({
|
|
id: expect.any(String),
|
|
username: "alice",
|
|
role: "user",
|
|
team: "team-default",
|
|
});
|
|
expect(state.inserts).toHaveLength(1);
|
|
const row = state.inserts[0];
|
|
expect(row.username).toBe("alice");
|
|
expect(row.role).toBe("user");
|
|
expect(row.team).toBe("team-default");
|
|
expect(row.authProvider).toBe("oidc");
|
|
expect(row.externalId).toBe("ext-1");
|
|
expect(row.email).toBe("new@example.com");
|
|
expect(row.passwordHash).toBeNull();
|
|
expect(row.mustChangePassword).toBe(false);
|
|
expect(state.auditCalls.at(-1)?.event).toBe("OIDC_USER_CREATED");
|
|
});
|
|
|
|
it("falls back to the sentinel team id when no Default team exists", async () => {
|
|
state.selectRows = [[], [], []]; // extId miss, username free, NO default team
|
|
const result = await resolveExternalUser(
|
|
baseParams({ autoCreate: true, email: "new@example.com" }),
|
|
);
|
|
expect(result.user?.team).toBe("default-team-00000000");
|
|
expect(state.inserts[0].team).toBe("default-team-00000000");
|
|
});
|
|
|
|
it("stores null email on the new row when no email is supplied", async () => {
|
|
state.selectRows = [[], [], [{ id: "team-default" }]];
|
|
await resolveExternalUser(baseParams({ autoCreate: true, email: undefined }));
|
|
expect(state.inserts[0].email).toBeNull();
|
|
});
|
|
|
|
it("denies auto-create when the default role is disabled (no insert)", async () => {
|
|
state.selectRows = [[]]; // extId miss; disabled-role guard fires before any lookup
|
|
const result = await resolveExternalUser(
|
|
baseParams({ autoCreate: true, defaultRole: "disabled" }),
|
|
);
|
|
expect(result).toEqual({ user: null, action: "denied", deniedReason: "user_disabled" });
|
|
expect(state.inserts).toHaveLength(0);
|
|
expect(state.auditCalls.at(-1)?.event).toBe("OIDC_LOGIN_FAILED");
|
|
});
|
|
|
|
it("denies auto-create when the user count is at the MAX_USERS limit", async () => {
|
|
state.maxUsers = 5;
|
|
state.selectRows = [
|
|
[], // extId miss
|
|
[{ count: 5 }], // count query: exactly at the cap (>= limit)
|
|
];
|
|
const result = await resolveExternalUser(baseParams({ autoCreate: true }));
|
|
expect(result).toEqual({ user: null, action: "denied", deniedReason: "user_limit_reached" });
|
|
expect(state.inserts).toHaveLength(0);
|
|
});
|
|
|
|
it("allows auto-create when the count is one below the limit (boundary)", async () => {
|
|
state.maxUsers = 5;
|
|
state.selectRows = [
|
|
[], // extId miss
|
|
[{ count: 4 }], // below cap
|
|
[], // username free
|
|
[{ id: "team-default" }],
|
|
];
|
|
const result = await resolveExternalUser(baseParams({ autoCreate: true }));
|
|
expect(result.action).toBe("created");
|
|
});
|
|
|
|
it("skips the count query entirely when MAX_USERS is 0 (unlimited)", async () => {
|
|
state.maxUsers = 0;
|
|
state.selectRows = [
|
|
[], // extId miss
|
|
[], // username free (NO count query consumed)
|
|
[{ id: "team-default" }],
|
|
];
|
|
const result = await resolveExternalUser(baseParams({ autoCreate: true }));
|
|
expect(result.action).toBe("created");
|
|
});
|
|
});
|
|
|
|
// ── 4. Terminal denied ───────────────────────────────────────────────────
|
|
|
|
describe("resolveExternalUser: terminal denied", () => {
|
|
it("denies with user_not_authorized when nothing matches and auto-create is off", async () => {
|
|
state.selectRows = [[]];
|
|
const result = await resolveExternalUser(baseParams());
|
|
expect(result).toEqual({ user: null, action: "denied", deniedReason: "user_not_authorized" });
|
|
});
|
|
|
|
it("audits the sanitized externalId on the not-authorized path", async () => {
|
|
state.selectRows = [[]];
|
|
await resolveExternalUser(baseParams({ externalId: "ext<script>1" }));
|
|
const denied = state.auditCalls.at(-1);
|
|
expect(denied?.event).toBe("OIDC_LOGIN_FAILED");
|
|
expect(denied?.details.reason).toBe("user_not_authorized");
|
|
// sanitizeAuditInput strips < and > from the raw external id.
|
|
expect(denied?.details.externalId).toBe("extscript1");
|
|
});
|
|
});
|