Files
SnapOtter/tests/unit/api/object-storage-workspace-cap.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

139 lines
6.1 KiB
TypeScript

import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { env } from "../../../apps/api/src/config.js";
import {
assertLocalCapacity,
computeWorkspaceUsedBytes,
isOverWorkspaceCap,
} from "../../../apps/api/src/lib/object-storage.js";
describe("isOverWorkspaceCap", () => {
it("is disabled (never over) when maxGb is 0", () => {
expect(isOverWorkspaceCap(999 * 1024 ** 3, 0)).toBe(false);
});
it("is false when usage is under the cap", () => {
expect(isOverWorkspaceCap(5 * 1024 ** 3, 10)).toBe(false);
});
it("is true when usage exceeds the cap", () => {
expect(isOverWorkspaceCap(11 * 1024 ** 3, 10)).toBe(true);
});
});
describe("computeWorkspaceUsedBytes", () => {
let root = "";
afterEach(async () => {
if (root) await rm(root, { recursive: true, force: true });
root = "";
});
it("sums file sizes across uploads/ and outputs/ job dirs", async () => {
root = await mkdtemp(join(tmpdir(), "snapotter-wscap-"));
await mkdir(join(root, "uploads", "job1"), { recursive: true });
await mkdir(join(root, "outputs", "job2"), { recursive: true });
await writeFile(join(root, "uploads", "job1", "a.bin"), Buffer.alloc(1000));
await writeFile(join(root, "outputs", "job2", "b.bin"), Buffer.alloc(2000));
expect(await computeWorkspaceUsedBytes(root)).toBe(3000);
});
it("returns 0 for an empty or missing workspace", async () => {
root = await mkdtemp(join(tmpdir(), "snapotter-wscap-"));
expect(await computeWorkspaceUsedBytes(root)).toBe(0);
expect(await computeWorkspaceUsedBytes(join(root, "nope"))).toBe(0);
});
});
// assertLocalCapacity wires the aggregate size cap and the free-space floor to
// the real WORKSPACE_PATH. This file never calls putObject, so the module's
// 30s workspace-size cache starts empty; each test picks a base timestamp far
// past any prior one so its first check recomputes rather than reusing a stale
// cached total.
describe("assertLocalCapacity capacity guard", () => {
const originalWorkspace = env.WORKSPACE_PATH;
const originalMaxGb = env.MAX_WORKSPACE_SIZE_GB;
let root = "";
let nowBase = 1_000_000_000_000;
afterEach(async () => {
vi.restoreAllMocks();
(env as { WORKSPACE_PATH: string }).WORKSPACE_PATH = originalWorkspace;
(env as { MAX_WORKSPACE_SIZE_GB: number }).MAX_WORKSPACE_SIZE_GB = originalMaxGb;
if (root) await rm(root, { recursive: true, force: true });
root = "";
// Advance the base well past the 30s cache window for the next test.
nowBase += 10 * 60_000;
});
it("returns without error when the workspace root does not exist yet", async () => {
(env as { WORKSPACE_PATH: string }).WORKSPACE_PATH = join(
tmpdir(),
`snapotter-absent-${process.pid}-${Date.now()}`,
);
(env as { MAX_WORKSPACE_SIZE_GB: number }).MAX_WORKSPACE_SIZE_GB = 0.000001;
await expect(assertLocalCapacity()).resolves.toBeUndefined();
});
it("passes when usage is under the configured aggregate cap", async () => {
root = await mkdtemp(join(tmpdir(), "snapotter-cap-under-"));
await mkdir(join(root, "uploads", "job1"), { recursive: true });
await writeFile(join(root, "uploads", "job1", "a.bin"), Buffer.alloc(4096));
(env as { WORKSPACE_PATH: string }).WORKSPACE_PATH = root;
(env as { MAX_WORKSPACE_SIZE_GB: number }).MAX_WORKSPACE_SIZE_GB = 10;
vi.spyOn(Date, "now").mockReturnValue(nowBase);
await expect(assertLocalCapacity()).resolves.toBeUndefined();
});
it("does not enforce the aggregate cap when MAX_WORKSPACE_SIZE_GB is 0", async () => {
root = await mkdtemp(join(tmpdir(), "snapotter-cap-off-"));
await mkdir(join(root, "outputs", "job1"), { recursive: true });
await writeFile(join(root, "outputs", "job1", "big.bin"), Buffer.alloc(2 * 1024 * 1024));
(env as { WORKSPACE_PATH: string }).WORKSPACE_PATH = root;
(env as { MAX_WORKSPACE_SIZE_GB: number }).MAX_WORKSPACE_SIZE_GB = 0;
vi.spyOn(Date, "now").mockReturnValue(nowBase);
// Cap disabled: even a tiny disk must not 503 on the cap path. statfs on a
// real temp dir reports the (ample) host free space, so the floor passes too.
await expect(assertLocalCapacity()).resolves.toBeUndefined();
});
it("throws a 503 when aggregate usage exceeds the cap", async () => {
root = await mkdtemp(join(tmpdir(), "snapotter-cap-over-"));
await mkdir(join(root, "outputs", "job1"), { recursive: true });
// 2 MiB of files; cap of 0.001 GB (~1 MiB) is exceeded.
await writeFile(join(root, "outputs", "job1", "big.bin"), Buffer.alloc(2 * 1024 * 1024));
(env as { WORKSPACE_PATH: string }).WORKSPACE_PATH = root;
(env as { MAX_WORKSPACE_SIZE_GB: number }).MAX_WORKSPACE_SIZE_GB = 0.001;
vi.spyOn(Date, "now").mockReturnValue(nowBase);
await expect(assertLocalCapacity()).rejects.toMatchObject({
statusCode: 503,
message: expect.stringMatching(/storage limit/i),
});
});
it("reuses the cached workspace total within the cache window", async () => {
root = await mkdtemp(join(tmpdir(), "snapotter-cap-cache-"));
await mkdir(join(root, "outputs", "job1"), { recursive: true });
const big = join(root, "outputs", "job1", "big.bin");
await writeFile(big, Buffer.alloc(2 * 1024 * 1024));
(env as { WORKSPACE_PATH: string }).WORKSPACE_PATH = root;
(env as { MAX_WORKSPACE_SIZE_GB: number }).MAX_WORKSPACE_SIZE_GB = 0.001;
// Freeze time so both calls fall inside the 30s window.
vi.spyOn(Date, "now").mockReturnValue(nowBase);
// First call computes the real total (2 MiB) and trips the cap.
await expect(assertLocalCapacity()).rejects.toMatchObject({ statusCode: 503 });
// Delete every file so a fresh recompute would report 0 bytes and pass. The
// second call still trips the cap, proving it read the cached total, not disk.
await unlink(big);
expect(existsSync(big)).toBe(false);
await expect(assertLocalCapacity()).rejects.toMatchObject({ statusCode: 503 });
});
});