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.
364 lines
17 KiB
TypeScript
364 lines
17 KiB
TypeScript
// Mutation-killing unit tests for apps/api/src/lib/object-storage.ts.
|
|
//
|
|
// These target Stryker survivors by asserting EXACT constructed keys/paths, the
|
|
// precise STORAGE_MODE branch chosen, the workspace-cap arithmetic and its
|
|
// boundary, path-segment validation, the operational-error code allowlist, and
|
|
// the chunk-byte accounting. A mutated string template, regex atom, path
|
|
// separator, or comparison changes the produced key or the branch taken, so the
|
|
// exact-value expectations below flip red under mutation.
|
|
//
|
|
// Companion files: object-storage-s3-mutation.test.ts covers the S3 dispatch
|
|
// branches (it vi.mocks the config module with STORAGE_MODE="s3", which cannot
|
|
// coexist with the real-env local tests here) and object-storage-statfs.test.ts
|
|
// covers the statfs free-space floor (it needs a node:fs/promises mock).
|
|
//
|
|
// Env note: WORKSPACE_PATH / MAX_WORKSPACE_SIZE_GB are mutated inside beforeAll,
|
|
// never in a describe body. Describe bodies all evaluate at collection time, so
|
|
// body-level assignment would let the last-collected describe clobber every
|
|
// other's workspace before any test runs.
|
|
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { PassThrough, Readable } from "node:stream";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { env } from "../../../apps/api/src/config.js";
|
|
import {
|
|
CAPACITY_CRITICAL_GB,
|
|
computeWorkspaceUsedBytes,
|
|
copyReadableToFile,
|
|
deleteObject,
|
|
getObjectSize,
|
|
isBelowCapacity,
|
|
isOverWorkspaceCap,
|
|
objectExists,
|
|
putObject,
|
|
putObjectStream,
|
|
} from "../../../apps/api/src/lib/object-storage.js";
|
|
|
|
/**
|
|
* Repoint the storage module at a fresh throwaway workspace with the aggregate
|
|
* cap disabled, restoring the originals afterwards. Returns the temp root. Used
|
|
* from beforeAll so describe-body evaluation never mutates shared env.
|
|
*/
|
|
function useThrowawayWorkspace(): { root: () => string } {
|
|
const originalWorkspace = env.WORKSPACE_PATH;
|
|
const originalMaxGb = env.MAX_WORKSPACE_SIZE_GB;
|
|
let root = "";
|
|
beforeAll(() => {
|
|
root = mkdtempSync(join(tmpdir(), "snapotter-objstore-mut-"));
|
|
(env as { WORKSPACE_PATH: string }).WORKSPACE_PATH = root;
|
|
(env as { MAX_WORKSPACE_SIZE_GB: number }).MAX_WORKSPACE_SIZE_GB = 0;
|
|
});
|
|
afterAll(() => {
|
|
(env as { WORKSPACE_PATH: string }).WORKSPACE_PATH = originalWorkspace;
|
|
(env as { MAX_WORKSPACE_SIZE_GB: number }).MAX_WORKSPACE_SIZE_GB = originalMaxGb;
|
|
if (root) rmSync(root, { recursive: true, force: true });
|
|
});
|
|
return { root: () => root };
|
|
}
|
|
|
|
// ── VALID_KEY regex (assertValidKey, source L25/L27-28) ──────────────
|
|
// Every public key-taking call funnels through assertValidKey. Kill regex-atom
|
|
// mutations by pinning the exact accept/reject verdict at each boundary the
|
|
// pattern encodes: the two allowed prefixes, the first-char class, the interior
|
|
// char class, a non-empty filename, and the null-byte / traversal rejections.
|
|
// assertValidKey is not exported, so probe it through putObject: a valid key
|
|
// reaches the write (resolves), an invalid one throws "Invalid object key".
|
|
|
|
describe("object key validation (VALID_KEY / assertValidKey)", () => {
|
|
useThrowawayWorkspace();
|
|
|
|
const accepted: Array<[string, string]> = [
|
|
["uploads prefix", "uploads/job1/file.bin"],
|
|
["outputs prefix", "outputs/job1/file.bin"],
|
|
["digit-leading jobId", "uploads/1abc/file.bin"],
|
|
["jobId with dot/underscore/dash interior", "uploads/a.b_c-d/file.bin"],
|
|
["single-char jobId", "uploads/a/file.bin"],
|
|
["filename with spaces and dots", "uploads/job1/my file.final.bin"],
|
|
];
|
|
it.each(accepted)("accepts a valid key: %s", async (_label, key) => {
|
|
await expect(putObject(key, Buffer.from("x"))).resolves.toBeUndefined();
|
|
});
|
|
|
|
const rejected: Array<[string, string]> = [
|
|
["wrong prefix (uploads-like but not exact)", "upload/job1/file.bin"],
|
|
["wrong prefix entirely", "secrets/job1/file.bin"],
|
|
["prefix must be at the very start (anchor ^)", "x/uploads/job1/file.bin"],
|
|
["jobId first char cannot be a dot", "uploads/.hidden/file.bin"],
|
|
["jobId first char cannot be an underscore", "uploads/_job/file.bin"],
|
|
["jobId first char cannot be a dash", "uploads/-job/file.bin"],
|
|
["empty filename after the slash is rejected", "uploads/job1/"],
|
|
["missing filename segment entirely", "uploads/job1"],
|
|
["a slash inside the filename is rejected (extra segment)", "uploads/job1/sub/deep.bin"],
|
|
["parent traversal in filename passes regex but the .. guard rejects", "uploads/job1/a..b"],
|
|
["classic dot-dot traversal", "outputs/../../etc/passwd"],
|
|
];
|
|
it.each(rejected)("rejects an invalid key: %s", async (_label, key) => {
|
|
await expect(putObject(key, Buffer.from("x"))).rejects.toThrow(/Invalid object key/);
|
|
});
|
|
|
|
it("rejects a null byte embedded in the filename", async () => {
|
|
await expect(putObject("uploads/job1/a |