Files
SnapOtter/tests/unit/api/support-bundle.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

302 lines
12 KiB
TypeScript

/**
* Unit tests for the support-bundle.ts library module.
*
* The integration suite (tests/integration/platform/support-bundle.test.ts)
* covers the happy path through the HTTP route. These unit tests drive the
* error/catch branches that the integration test cannot reach with a real
* DB and filesystem: DB query failures, an unreadable log file, a missing
* LOG_DIR, a statfs failure, and the various env-redaction branches.
*
* Strategy: mock node:fs, ../config.js, and ../db/index.js, then import
* buildSupportBundle directly, consume the returned zip stream to
* completion, and unzip it with AdmZip to assert on the produced entries.
*/
import { Buffer } from "node:buffer";
import type { Readable } from "node:stream";
import AdmZip from "adm-zip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Hoisted mock state ──────────────────────────────────────────────────
const fsMock = vi.hoisted(() => ({
readdirSync: vi.fn(),
readFileSync: vi.fn(),
statfsSync: vi.fn(),
}));
const dbMock = vi.hoisted(() => ({
execute: vi.fn(),
selectResult: {
promise: Promise.resolve([]) as Promise<unknown>,
},
}));
const envMock = vi.hoisted(() => ({
env: {
LOG_DIR: "/var/log/snapotter",
WORKSPACE_PATH: "/data/workspace",
DATABASE_URL: "postgres://user:secret@db:5432/snapotter",
REDIS_URL: "redis://user:secret@redis:6379",
DEFAULT_PASSWORD: "hunter2",
SESSION_SECRET: "topsecret",
SENTRY_DSN: "https://abc@sentry.io/1",
S3_ACCESS_KEY_ID: "AKIA123",
PORT: 13490,
AUTH_ENABLED: true,
} as Record<string, unknown>,
}));
// ── Module mocks ────────────────────────────────────────────────────────
vi.mock("node:fs", () => ({
readdirSync: fsMock.readdirSync,
readFileSync: fsMock.readFileSync,
statfsSync: fsMock.statfsSync,
}));
vi.mock("../../../apps/api/src/config.js", () => envMock);
// A drizzle-style select chain that terminates in a thenable at .limit().
function makeSelectChain() {
const chain = {
select: () => chain,
from: () => chain,
where: () => chain,
orderBy: () => chain,
limit: () => dbMock.selectResult.promise,
};
return chain;
}
vi.mock("../../../apps/api/src/db/index.js", () => ({
db: {
execute: (...args: unknown[]) => dbMock.execute(...args),
select: () => makeSelectChain(),
},
schema: {
jobs: {
id: {},
toolId: {},
pool: {},
error: {},
createdAt: {},
durationMs: {},
status: {},
},
},
}));
import { buildSupportBundle } from "../../../apps/api/src/lib/support-bundle.js";
// ── Helpers ─────────────────────────────────────────────────────────────
async function collect(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
stream.on("data", (c: Buffer) => chunks.push(Buffer.from(c)));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
}
async function buildAndUnzip(): Promise<AdmZip> {
const stream = buildSupportBundle();
const buf = await collect(stream);
return new AdmZip(buf);
}
function readEntry(zip: AdmZip, name: string): unknown {
const entry = zip.getEntry(name);
if (!entry) throw new Error(`missing entry: ${name}`);
return JSON.parse(entry.getData().toString("utf-8"));
}
// ── Default happy state, reset per test ─────────────────────────────────
beforeEach(() => {
fsMock.readdirSync.mockReset();
fsMock.readFileSync.mockReset();
fsMock.statfsSync.mockReset();
dbMock.execute.mockReset();
// Default: LOG_DIR has one readable file.
fsMock.readdirSync.mockReturnValue(["app.log"]);
fsMock.readFileSync.mockReturnValue(Buffer.from("log line\n"));
// Default: statfs succeeds.
fsMock.statfsSync.mockReturnValue({ bfree: 100, bsize: 4096 });
// Default: db.execute returns two table counts.
dbMock.execute.mockResolvedValue({
rows: [
{ relname: "jobs", n_live_tup: 5 },
{ relname: "users", n_live_tup: 2 },
],
});
// Default: failedJobs select resolves to one row.
dbMock.selectResult.promise = Promise.resolve([
{ id: "j1", toolId: "resize", pool: "image", error: null, createdAt: null, durationMs: 10 },
]);
// Restore env mutations from prior tests.
envMock.env.DATABASE_URL = "postgres://user:secret@db:5432/snapotter";
envMock.env.LOG_DIR = "/var/log/snapotter";
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── config.json redaction ───────────────────────────────────────────────
describe("buildSupportBundle config.json redaction", () => {
it("masks userinfo in DATABASE_URL and REDIS_URL", async () => {
const zip = await buildAndUnzip();
const config = readEntry(zip, "config.json") as Record<string, unknown>;
expect(config.DATABASE_URL).toBe("postgres://***@db:5432/snapotter");
expect(config.REDIS_URL).toBe("redis://***@redis:6379");
});
it("fully redacts keys matching PASSWORD|SECRET|KEY|DSN", async () => {
const zip = await buildAndUnzip();
const config = readEntry(zip, "config.json") as Record<string, unknown>;
expect(config.DEFAULT_PASSWORD).toBe("<redacted>");
expect(config.SESSION_SECRET).toBe("<redacted>");
expect(config.SENTRY_DSN).toBe("<redacted>");
expect(config.S3_ACCESS_KEY_ID).toBe("<redacted>");
});
it("passes non-sensitive keys through unchanged and stamps version + node", async () => {
const zip = await buildAndUnzip();
const config = readEntry(zip, "config.json") as Record<string, unknown>;
expect(config.PORT).toBe(13490);
expect(config.AUTH_ENABLED).toBe(true);
// version comes from APP_VERSION, node from process.version.
expect(typeof config.version).toBe("string");
expect(config.node).toBe(process.version);
});
it("does not apply userinfo masking when a URL key is not a string", async () => {
// typeof value === "string" is false, and DATABASE_URL matches no
// REDACT_PATTERN token, so it falls through to the passthrough branch.
envMock.env.DATABASE_URL = undefined;
const zip = await buildAndUnzip();
const config = readEntry(zip, "config.json") as Record<string, unknown>;
// undefined values are dropped by JSON.stringify.
expect("DATABASE_URL" in config).toBe(false);
});
});
// ── log file collection ─────────────────────────────────────────────────
describe("buildSupportBundle log collection", () => {
it("includes readable log files under logs/", async () => {
fsMock.readdirSync.mockReturnValue(["a.log", "b.log"]);
fsMock.readFileSync.mockReturnValue(Buffer.from("data"));
const zip = await buildAndUnzip();
const names = zip.getEntries().map((e) => e.entryName);
expect(names).toContain("logs/a.log");
expect(names).toContain("logs/b.log");
});
it("skips an unreadable log file but keeps the rest", async () => {
fsMock.readdirSync.mockReturnValue(["ok.log", "locked.log"]);
fsMock.readFileSync.mockImplementation((p: string) => {
if (String(p).endsWith("locked.log")) throw new Error("EACCES");
return Buffer.from("ok");
});
const zip = await buildAndUnzip();
const names = zip.getEntries().map((e) => e.entryName);
expect(names).toContain("logs/ok.log");
expect(names).not.toContain("logs/locked.log");
// The bundle is still assembled: config.json is present.
expect(names).toContain("config.json");
});
it("silently skips log collection when LOG_DIR is missing", async () => {
fsMock.readdirSync.mockImplementation(() => {
throw new Error("ENOENT");
});
const zip = await buildAndUnzip();
const names = zip.getEntries().map((e) => e.entryName);
expect(names.some((n) => n.startsWith("logs/"))).toBe(false);
// The rest of the bundle still assembles.
expect(names).toContain("config.json");
expect(names).toContain("db-counts.json");
expect(names).toContain("host.json");
});
});
// ── db-counts.json ──────────────────────────────────────────────────────
describe("buildSupportBundle db-counts.json", () => {
it("includes the rows returned by pg_stat_user_tables", async () => {
const zip = await buildAndUnzip();
const counts = readEntry(zip, "db-counts.json") as Array<Record<string, unknown>>;
expect(counts).toEqual([
{ relname: "jobs", n_live_tup: 5 },
{ relname: "users", n_live_tup: 2 },
]);
});
it("falls back to an empty array when the count query throws", async () => {
dbMock.execute.mockRejectedValue(new Error("connection reset"));
const zip = await buildAndUnzip();
const counts = readEntry(zip, "db-counts.json");
expect(counts).toEqual([]);
});
});
// ── failed-jobs.json ────────────────────────────────────────────────────
describe("buildSupportBundle failed-jobs.json", () => {
it("includes the rows returned by the jobs query", async () => {
const zip = await buildAndUnzip();
const jobs = readEntry(zip, "failed-jobs.json") as Array<Record<string, unknown>>;
expect(jobs).toHaveLength(1);
expect(jobs[0]).toMatchObject({ id: "j1", toolId: "resize", pool: "image" });
});
it("falls back to an empty array when the jobs query throws", async () => {
dbMock.selectResult.promise = Promise.reject(new Error("query failed"));
const zip = await buildAndUnzip();
const jobs = readEntry(zip, "failed-jobs.json");
expect(jobs).toEqual([]);
});
});
// ── host.json ───────────────────────────────────────────────────────────
describe("buildSupportBundle host.json", () => {
it("reports workspace free bytes as bfree * bsize when statfs succeeds", async () => {
fsMock.statfsSync.mockReturnValue({ bfree: 100, bsize: 4096 });
const zip = await buildAndUnzip();
const host = readEntry(zip, "host.json") as Record<string, unknown>;
expect(host.workspaceFreeBytes).toBe(100 * 4096);
// Platform metadata is always populated from os.*.
expect(typeof host.platform).toBe("string");
expect(typeof host.cpus).toBe("number");
});
it("reports workspaceFreeBytes: null when statfs throws", async () => {
fsMock.statfsSync.mockImplementation(() => {
throw new Error("ENOENT");
});
const zip = await buildAndUnzip();
const host = readEntry(zip, "host.json") as Record<string, unknown>;
expect(host.workspaceFreeBytes).toBeNull();
});
});
// ── full-bundle smoke ───────────────────────────────────────────────────
describe("buildSupportBundle assembly", () => {
it("produces a zip containing all five expected artifacts", async () => {
const zip = await buildAndUnzip();
const names = zip.getEntries().map((e) => e.entryName);
expect(names).toContain("config.json");
expect(names).toContain("db-counts.json");
expect(names).toContain("failed-jobs.json");
expect(names).toContain("host.json");
expect(names).toContain("logs/app.log");
});
});