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

186 lines
7.2 KiB
TypeScript

import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
// storage-writable reads the configured storage paths from config.js. Mock it
// with a mutable env object so each test can point the paths at a temp dir.
// vi.hoisted keeps the object available to the hoisted vi.mock factory.
const mockEnv = vi.hoisted(() => ({
STORAGE_MODE: "local",
WORKSPACE_PATH: "",
FILES_STORAGE_PATH: "",
}));
vi.mock("../../../apps/api/src/config.js", () => ({ env: mockEnv }));
import {
assertStorageWritable,
isDirWritable,
storagePermissionMessage,
} from "../../../apps/api/src/lib/storage-writable.js";
// A read-only directory does not block writes for root (DAC_OVERRIDE), so the
// "not writable" assertions only hold for an unprivileged user.
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
let root: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), "storage-writable-"));
});
afterAll(() => {
// Restore perms so cleanup can recurse into the read-only dir.
try {
chmodSync(join(root, "readonly"), 0o755);
} catch {
/* may not exist */
}
rmSync(root, { recursive: true, force: true });
});
describe("isDirWritable", () => {
it("returns true for an existing writable directory", async () => {
const dir = join(root, "writable");
mkdirSync(dir, { recursive: true });
expect(await isDirWritable(dir)).toBe(true);
});
it("returns true for a missing directory whose parent is writable (creates it)", async () => {
const dir = join(root, "nested", "deep");
expect(await isDirWritable(dir)).toBe(true);
});
it.skipIf(isRoot)("returns false for a read-only directory", async () => {
const dir = join(root, "readonly");
mkdirSync(dir, { recursive: true });
chmodSync(dir, 0o555);
expect(await isDirWritable(dir)).toBe(false);
});
it("rethrows a non-permission error (ENOTDIR) instead of returning false", async () => {
// A path whose parent component is a regular file makes mkdir fail with
// ENOTDIR, which is not in NOT_WRITABLE_CODES, so it must propagate rather
// than be misreported as "not writable".
const filePath = join(root, "a-file");
writeFileSync(filePath, "x");
const dir = join(filePath, "sub");
await expect(isDirWritable(dir)).rejects.toMatchObject({ code: "ENOTDIR" });
});
});
describe("storagePermissionMessage", () => {
it("names the directory and gives an actionable chown remediation", () => {
const msg = storagePermissionMessage("/tmp/workspace");
expect(msg).toContain("/tmp/workspace");
expect(msg.toLowerCase()).toContain("not writable");
expect(msg).toContain("chown");
// Includes the running uid and gid so the operator knows what to chown to.
expect(msg).toMatch(/uid=/);
expect(msg).toMatch(/gid=/);
// Lists the non-root remediation and the docs link.
expect(msg).toContain("PUID/PGID");
expect(msg).toContain("https://docs.snapotter.com/guide/deployment#storage-permissions");
});
it("reports the real numeric uid/gid when getuid/getgid are available", () => {
// On POSIX, process.getuid()/getgid() exist; the message must echo those
// exact numbers rather than the "?" fallback.
if (typeof process.getuid !== "function" || typeof process.getgid !== "function") return;
const uid = process.getuid();
const gid = process.getgid();
const msg = storagePermissionMessage("/srv/files");
expect(msg).toContain(`uid=${uid} gid=${gid}`);
expect(msg).toContain(`chown -R ${uid}:${gid} <host-path>`);
});
it('falls back to "?" for uid/gid when getuid/getgid are unavailable', () => {
// Simulate a runtime (e.g. Windows) where these functions do not exist.
const origUid = process.getuid;
const origGid = process.getgid;
// getuid/getgid are optional (undefined on Windows), so this typechecks.
process.getuid = undefined;
process.getgid = undefined;
try {
const msg = storagePermissionMessage("/srv/files");
expect(msg).toContain("uid=? gid=?");
expect(msg).toContain("chown -R ?:? <host-path>");
} finally {
process.getuid = origUid;
process.getgid = origGid;
}
});
});
describe("assertStorageWritable", () => {
it("resolves when both storage paths are writable", async () => {
mockEnv.STORAGE_MODE = "local";
mockEnv.WORKSPACE_PATH = join(root, "ws-ok");
mockEnv.FILES_STORAGE_PATH = join(root, "files-ok");
await expect(assertStorageWritable()).resolves.toBeUndefined();
});
it.skipIf(isRoot)(
"rejects with an actionable message when the workspace is not writable",
async () => {
const ws = join(root, "ws-ro");
mkdirSync(ws, { recursive: true });
chmodSync(ws, 0o555);
mockEnv.STORAGE_MODE = "local";
mockEnv.WORKSPACE_PATH = ws;
mockEnv.FILES_STORAGE_PATH = join(root, "files-ok2");
await expect(assertStorageWritable()).rejects.toThrow(/not writable/i);
await expect(assertStorageWritable()).rejects.toThrow(ws);
chmodSync(ws, 0o755);
},
);
it.skipIf(isRoot)(
"rejects naming the files-storage path when only the second dir is unwritable",
async () => {
// Workspace is fine; the loop must advance to FILES_STORAGE_PATH and fail
// there, and the thrown message must name that second directory.
const files = join(root, "files-ro");
mkdirSync(files, { recursive: true });
chmodSync(files, 0o555);
mockEnv.STORAGE_MODE = "local";
mockEnv.WORKSPACE_PATH = join(root, "ws-ok3");
mockEnv.FILES_STORAGE_PATH = files;
await expect(assertStorageWritable()).rejects.toThrow(/not writable/i);
await expect(assertStorageWritable()).rejects.toThrow(files);
chmodSync(files, 0o755);
},
);
it("is a no-op in S3 storage mode (does not touch the filesystem)", async () => {
mockEnv.STORAGE_MODE = "s3";
mockEnv.WORKSPACE_PATH = "/nonexistent/should-not-be-touched";
mockEnv.FILES_STORAGE_PATH = "/nonexistent/should-not-be-touched";
await expect(assertStorageWritable()).resolves.toBeUndefined();
});
});
// The `code &&` guard short-circuits when the thrown error carries no `code`
// property (not a NodeJS.ErrnoException). Real filesystem faults always set a
// code, so mock node:fs/promises to reject the probe write with a plain Error
// and confirm it propagates rather than being swallowed as "not writable".
describe("isDirWritable with an error that has no code property", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("node:fs/promises");
});
it("rethrows a codeless error (falsy code short-circuits the writable check)", async () => {
vi.resetModules();
const codeless = new Error("mystery failure");
vi.doMock("node:fs/promises", () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockRejectedValue(codeless),
rm: vi.fn().mockResolvedValue(undefined),
}));
const mod = await import("../../../apps/api/src/lib/storage-writable.js");
await expect(mod.isDirWritable("/any/dir")).rejects.toThrow("mystery failure");
});
});