mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Remove hardcoded --platform=linux/amd64 from Dockerfile so buildx produces native arm64 images for Apple Silicon and Raspberry Pi. Add audit logging for auth events, harden file storage with extension whitelists and double-extension attack prevention, reject null-byte buffers in validation, add data-testid attributes to all tool settings components, update deployment docs with architecture notes and correct CI workflow references, and fix unit test mock to match throwWithMessage error extraction.
969 lines
35 KiB
TypeScript
969 lines
35 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join } from "node:path";
|
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const FIXTURES = join(__dirname, "../../fixtures");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. File validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// We need to mock `../config.js` which file-validation.ts imports as `env`.
|
|
// Provide a minimal env object with the fields file-validation.ts uses.
|
|
vi.mock("../../../apps/api/src/config.js", () => ({
|
|
env: {
|
|
MAX_MEGAPIXELS: 100,
|
|
WORKSPACE_PATH: "/tmp/test-workspace",
|
|
},
|
|
}));
|
|
|
|
describe("validateImageBuffer", () => {
|
|
let validateImageBuffer: typeof import("../../../apps/api/src/lib/file-validation.js").validateImageBuffer;
|
|
|
|
beforeEach(async () => {
|
|
// Re-import to pick up mock
|
|
const mod = await import("../../../apps/api/src/lib/file-validation.js");
|
|
validateImageBuffer = mod.validateImageBuffer;
|
|
});
|
|
|
|
// -- Valid formats --------------------------------------------------------
|
|
|
|
it("accepts a valid PNG file", async () => {
|
|
const buf = await readFile(join(FIXTURES, "test-200x150.png"));
|
|
const result = await validateImageBuffer(buf);
|
|
expect(result.valid).toBe(true);
|
|
if (result.valid) {
|
|
expect(result.format).toBe("png");
|
|
expect(result.width).toBe(200);
|
|
expect(result.height).toBe(150);
|
|
}
|
|
});
|
|
|
|
it("accepts a valid JPEG file", async () => {
|
|
const buf = await readFile(join(FIXTURES, "test-100x100.jpg"));
|
|
const result = await validateImageBuffer(buf);
|
|
expect(result.valid).toBe(true);
|
|
if (result.valid) {
|
|
expect(result.format).toBe("jpeg");
|
|
expect(result.width).toBe(100);
|
|
expect(result.height).toBe(100);
|
|
}
|
|
});
|
|
|
|
it("accepts a valid WebP file", async () => {
|
|
const buf = await readFile(join(FIXTURES, "test-50x50.webp"));
|
|
const result = await validateImageBuffer(buf);
|
|
expect(result.valid).toBe(true);
|
|
if (result.valid) {
|
|
expect(result.format).toBe("webp");
|
|
expect(result.width).toBe(50);
|
|
expect(result.height).toBe(50);
|
|
}
|
|
});
|
|
|
|
it("accepts a tiny 1x1 PNG file", async () => {
|
|
const buf = await readFile(join(FIXTURES, "test-1x1.png"));
|
|
const result = await validateImageBuffer(buf);
|
|
expect(result.valid).toBe(true);
|
|
if (result.valid) {
|
|
expect(result.format).toBe("png");
|
|
expect(result.width).toBe(1);
|
|
expect(result.height).toBe(1);
|
|
}
|
|
});
|
|
|
|
// -- Synthetic valid buffers for formats without fixtures -----------------
|
|
|
|
it("accepts a GIF buffer with correct magic bytes", async () => {
|
|
// Minimal GIF89a: header + logical screen descriptor + terminator
|
|
const gif = Buffer.from("474946383961010001000000002c00000000010001000002024401003b", "hex");
|
|
const result = await validateImageBuffer(gif);
|
|
// sharp may or may not parse this minimal GIF; what matters is magic bytes pass
|
|
// If sharp fails metadata, that is "Failed to read image metadata"
|
|
expect(result).toBeDefined();
|
|
if (result.valid) {
|
|
expect(result.format).toBe("gif");
|
|
}
|
|
});
|
|
|
|
it("accepts a BMP buffer with correct magic bytes", async () => {
|
|
// Build a minimal 1x1 24-bit BMP (62 bytes header + 4 bytes pixel data)
|
|
const bmp = Buffer.alloc(66);
|
|
// BM signature
|
|
bmp[0] = 0x42;
|
|
bmp[1] = 0x4d;
|
|
// File size (66 bytes, little-endian)
|
|
bmp.writeUInt32LE(66, 2);
|
|
// Reserved
|
|
bmp.writeUInt32LE(0, 6);
|
|
// Pixel data offset
|
|
bmp.writeUInt32LE(62, 10);
|
|
// DIB header size (40 = BITMAPINFOHEADER)
|
|
bmp.writeUInt32LE(40, 14);
|
|
// Width = 1
|
|
bmp.writeInt32LE(1, 18);
|
|
// Height = 1
|
|
bmp.writeInt32LE(1, 22);
|
|
// Planes = 1
|
|
bmp.writeUInt16LE(1, 26);
|
|
// Bits per pixel = 24
|
|
bmp.writeUInt16LE(24, 28);
|
|
// Compression = 0 (BI_RGB)
|
|
bmp.writeUInt32LE(0, 30);
|
|
// Image size (can be 0 for BI_RGB)
|
|
bmp.writeUInt32LE(0, 34);
|
|
// X/Y pixels per meter
|
|
bmp.writeInt32LE(2835, 38);
|
|
bmp.writeInt32LE(2835, 42);
|
|
// Colors used / important
|
|
bmp.writeUInt32LE(0, 46);
|
|
bmp.writeUInt32LE(0, 50);
|
|
|
|
const result = await validateImageBuffer(bmp);
|
|
expect(result).toBeDefined();
|
|
if (result.valid) {
|
|
expect(result.format).toBe("bmp");
|
|
}
|
|
});
|
|
|
|
it("accepts a TIFF buffer (little-endian byte order)", async () => {
|
|
// Minimal TIFF is complex; just verify magic bytes detection works
|
|
// and sharp either parses or gives metadata error (not "unrecognized format")
|
|
const tiffLE = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]);
|
|
const result = await validateImageBuffer(tiffLE);
|
|
// Magic bytes should be detected as tiff, but sharp may fail on a truncated TIFF
|
|
expect(result).toBeDefined();
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Failed to read image metadata");
|
|
}
|
|
});
|
|
|
|
it("accepts a TIFF buffer (big-endian byte order)", async () => {
|
|
const tiffBE = Buffer.from([0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08]);
|
|
const result = await validateImageBuffer(tiffBE);
|
|
expect(result).toBeDefined();
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Failed to read image metadata");
|
|
}
|
|
});
|
|
|
|
// -- Empty / null / missing -----------------------------------------------
|
|
|
|
it("rejects an empty buffer", async () => {
|
|
const result = await validateImageBuffer(Buffer.alloc(0));
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("File is empty");
|
|
}
|
|
});
|
|
|
|
it("rejects null passed as buffer", async () => {
|
|
const result = await validateImageBuffer(null as unknown as Buffer);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("File is empty");
|
|
}
|
|
});
|
|
|
|
it("rejects undefined passed as buffer", async () => {
|
|
const result = await validateImageBuffer(undefined as unknown as Buffer);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("File is empty");
|
|
}
|
|
});
|
|
|
|
// -- Random / garbage data ------------------------------------------------
|
|
|
|
it("rejects a buffer of random bytes", async () => {
|
|
const garbage = Buffer.from(Array.from({ length: 256 }, () => Math.floor(Math.random() * 256)));
|
|
// Ensure the first bytes do not accidentally match any magic signature
|
|
garbage[0] = 0x00;
|
|
garbage[1] = 0x00;
|
|
const result = await validateImageBuffer(garbage);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Unrecognized image format");
|
|
}
|
|
});
|
|
|
|
it("rejects a text file disguised with no image magic bytes", async () => {
|
|
const text = Buffer.from("This is definitely not an image file. Lorem ipsum dolor sit amet.");
|
|
const result = await validateImageBuffer(text);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Unrecognized image format");
|
|
}
|
|
});
|
|
|
|
it("rejects an HTML file", async () => {
|
|
const html = Buffer.from("<html><body><h1>Hello</h1></body></html>");
|
|
const result = await validateImageBuffer(html);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects a JSON file", async () => {
|
|
const json = Buffer.from(JSON.stringify({ image: "fake.png", width: 100 }));
|
|
const result = await validateImageBuffer(json);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
// -- Truncated headers ----------------------------------------------------
|
|
|
|
it("rejects a buffer with only the first byte of a PNG header", async () => {
|
|
const partial = Buffer.from([0x89]);
|
|
const result = await validateImageBuffer(partial);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Unrecognized image format");
|
|
}
|
|
});
|
|
|
|
it("rejects a truncated PNG header (3 of 4 magic bytes)", async () => {
|
|
const partial = Buffer.from([0x89, 0x50, 0x4e]);
|
|
const result = await validateImageBuffer(partial);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects a buffer with PNG magic bytes but no real image data", async () => {
|
|
// Correct 4-byte PNG signature but nothing valid after
|
|
const fakePng = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x00, 0x00]);
|
|
const result = await validateImageBuffer(fakePng);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Failed to read image metadata");
|
|
}
|
|
});
|
|
|
|
it("rejects a buffer with JPEG magic bytes but truncated body", async () => {
|
|
const fakeJpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]);
|
|
const result = await validateImageBuffer(fakeJpeg);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Failed to read image metadata");
|
|
}
|
|
});
|
|
|
|
// -- RIFF container that is NOT WebP (e.g. WAV/AVI) ----------------------
|
|
|
|
it("rejects a RIFF container that is not WebP (e.g. WAVE)", async () => {
|
|
// RIFF....WAVE
|
|
const riffWave = Buffer.alloc(12);
|
|
riffWave.write("RIFF", 0, "ascii");
|
|
riffWave.writeUInt32LE(4, 4); // chunk size
|
|
riffWave.write("WAVE", 8, "ascii");
|
|
const result = await validateImageBuffer(riffWave);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Unrecognized image format");
|
|
}
|
|
});
|
|
|
|
it("rejects a RIFF container that is AVI", async () => {
|
|
const riffAvi = Buffer.alloc(12);
|
|
riffAvi.write("RIFF", 0, "ascii");
|
|
riffAvi.writeUInt32LE(4, 4);
|
|
riffAvi.write("AVI ", 8, "ascii");
|
|
const result = await validateImageBuffer(riffAvi);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Unrecognized image format");
|
|
}
|
|
});
|
|
|
|
it("rejects a RIFF container too short to contain WEBP signature", async () => {
|
|
// Only 8 bytes of RIFF -- no room for the format tag at bytes 8-11
|
|
const shortRiff = Buffer.alloc(8);
|
|
shortRiff.write("RIFF", 0, "ascii");
|
|
shortRiff.writeUInt32LE(0, 4);
|
|
const result = await validateImageBuffer(shortRiff);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
// -- Partial magic bytes edge cases ---------------------------------------
|
|
|
|
it("rejects a single-byte buffer (0xFF -- JPEG first byte only)", async () => {
|
|
const result = await validateImageBuffer(Buffer.from([0xff]));
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects two JPEG magic bytes without the third", async () => {
|
|
const result = await validateImageBuffer(Buffer.from([0xff, 0xd8]));
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects BMP magic with no actual BMP structure", async () => {
|
|
const fakeBmp = Buffer.from([0x42, 0x4d, 0x00, 0x00]);
|
|
const result = await validateImageBuffer(fakeBmp);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe("Failed to read image metadata");
|
|
}
|
|
});
|
|
|
|
// -- Oversized image (megapixel limit) ------------------------------------
|
|
|
|
it("rejects an image that exceeds MAX_MEGAPIXELS", async () => {
|
|
// We cannot easily build a 100MP+ image buffer in a test, so re-mock env
|
|
// with a very low limit (0.0001 MP = 100 pixels) and test with the 200x150 fixture
|
|
const origMod = await import("../../../apps/api/src/config.js");
|
|
const savedMax = origMod.env.MAX_MEGAPIXELS;
|
|
origMod.env.MAX_MEGAPIXELS = 0.0001; // 100 pixels -- 200x150 = 30000px >> 100
|
|
|
|
try {
|
|
const buf = await readFile(join(FIXTURES, "test-200x150.png"));
|
|
const result = await validateImageBuffer(buf);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toContain("exceeds maximum size");
|
|
expect(result.reason).toContain("MP");
|
|
}
|
|
} finally {
|
|
origMod.env.MAX_MEGAPIXELS = savedMax;
|
|
}
|
|
});
|
|
|
|
// -- Polyglot / tricky inputs ---------------------------------------------
|
|
|
|
it("rejects a ZIP file (PK magic 0x50 0x4B)", async () => {
|
|
const zip = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00, 0x00, 0x00]);
|
|
const result = await validateImageBuffer(zip);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects a PDF file (%PDF-)", async () => {
|
|
const pdf = Buffer.from("%PDF-1.4 fake content");
|
|
const result = await validateImageBuffer(pdf);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it("rejects an EXE file (MZ header)", async () => {
|
|
const exe = Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00]);
|
|
const result = await validateImageBuffer(exe);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Workspace
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("workspace", () => {
|
|
// Use a real temp directory to avoid polluting the project
|
|
let TEST_WORKSPACE: string;
|
|
|
|
beforeEach(async () => {
|
|
TEST_WORKSPACE = join(tmpdir(), `stirling-test-workspace-${randomUUID()}`);
|
|
await mkdir(TEST_WORKSPACE, { recursive: true });
|
|
|
|
// Point the env mock's WORKSPACE_PATH at our temp dir
|
|
const configMod = await import("../../../apps/api/src/config.js");
|
|
configMod.env.WORKSPACE_PATH = TEST_WORKSPACE;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up the temp dir
|
|
await rm(TEST_WORKSPACE, { recursive: true, force: true });
|
|
});
|
|
|
|
it("createWorkspace creates input and output subdirectories", async () => {
|
|
const { createWorkspace } = await import("../../../apps/api/src/lib/workspace.js");
|
|
const jobId = randomUUID();
|
|
const root = await createWorkspace(jobId);
|
|
|
|
expect(root).toBe(join(TEST_WORKSPACE, jobId));
|
|
expect(existsSync(join(root, "input"))).toBe(true);
|
|
expect(existsSync(join(root, "output"))).toBe(true);
|
|
});
|
|
|
|
it("createWorkspace returns the workspace root path", async () => {
|
|
const { createWorkspace } = await import("../../../apps/api/src/lib/workspace.js");
|
|
const jobId = "my-test-job-123";
|
|
const root = await createWorkspace(jobId);
|
|
expect(root).toBe(join(TEST_WORKSPACE, jobId));
|
|
});
|
|
|
|
it("getWorkspacePath returns correct path without creating dirs", async () => {
|
|
const { getWorkspacePath } = await import("../../../apps/api/src/lib/workspace.js");
|
|
const jobId = "path-check-id";
|
|
const result = getWorkspacePath(jobId);
|
|
expect(result).toBe(join(TEST_WORKSPACE, jobId));
|
|
// Should NOT have created the directory
|
|
expect(existsSync(join(TEST_WORKSPACE, jobId))).toBe(false);
|
|
});
|
|
|
|
it("cleanupWorkspace removes the entire workspace directory", async () => {
|
|
const { createWorkspace, cleanupWorkspace } = await import(
|
|
"../../../apps/api/src/lib/workspace.js"
|
|
);
|
|
const jobId = randomUUID();
|
|
const root = await createWorkspace(jobId);
|
|
|
|
// Verify it exists
|
|
expect(existsSync(root)).toBe(true);
|
|
|
|
await cleanupWorkspace(jobId);
|
|
|
|
// Verify it is gone
|
|
expect(existsSync(root)).toBe(false);
|
|
});
|
|
|
|
it("cleanupWorkspace on a non-existent directory does not throw", async () => {
|
|
const { cleanupWorkspace } = await import("../../../apps/api/src/lib/workspace.js");
|
|
// This job was never created
|
|
await expect(cleanupWorkspace("does-not-exist-" + randomUUID())).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("createWorkspace is idempotent (calling twice does not throw)", async () => {
|
|
const { createWorkspace } = await import("../../../apps/api/src/lib/workspace.js");
|
|
const jobId = randomUUID();
|
|
await createWorkspace(jobId);
|
|
await expect(createWorkspace(jobId)).resolves.toBeDefined();
|
|
});
|
|
|
|
it("createWorkspace with empty string job ID still creates a directory", async () => {
|
|
const { createWorkspace } = await import("../../../apps/api/src/lib/workspace.js");
|
|
// Empty string is technically allowed by mkdir, it just uses WORKSPACE_PATH as the root
|
|
const root = await createWorkspace("");
|
|
expect(existsSync(root)).toBe(true);
|
|
});
|
|
|
|
it("workspace directories are nested under the configured WORKSPACE_PATH", async () => {
|
|
const { createWorkspace, getWorkspacePath } = await import(
|
|
"../../../apps/api/src/lib/workspace.js"
|
|
);
|
|
const jobId = randomUUID();
|
|
const wsPath = getWorkspacePath(jobId);
|
|
const root = await createWorkspace(jobId);
|
|
|
|
expect(wsPath).toBe(root);
|
|
expect(root.startsWith(TEST_WORKSPACE)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Env loading
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("loadEnv", () => {
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
// Reset to a clean slate before each test --
|
|
// wipe all keys that our schema cares about so defaults kick in
|
|
const keysToClean = [
|
|
"PORT",
|
|
"AUTH_ENABLED",
|
|
"DEFAULT_USERNAME",
|
|
"DEFAULT_PASSWORD",
|
|
"STORAGE_MODE",
|
|
"FILE_MAX_AGE_HOURS",
|
|
"CLEANUP_INTERVAL_MINUTES",
|
|
"MAX_UPLOAD_SIZE_MB",
|
|
"MAX_BATCH_SIZE",
|
|
"CONCURRENT_JOBS",
|
|
"MAX_MEGAPIXELS",
|
|
"RATE_LIMIT_PER_MIN",
|
|
"SKIP_MUST_CHANGE_PASSWORD",
|
|
"DB_PATH",
|
|
"WORKSPACE_PATH",
|
|
"DEFAULT_THEME",
|
|
"DEFAULT_LOCALE",
|
|
"APP_NAME",
|
|
];
|
|
for (const key of keysToClean) {
|
|
delete process.env[key];
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
// Restore original process.env
|
|
for (const key of Object.keys(process.env)) {
|
|
if (!(key in originalEnv)) delete process.env[key];
|
|
}
|
|
Object.assign(process.env, originalEnv);
|
|
});
|
|
|
|
// We import the raw loadEnv function (not the cached `env` from config.ts)
|
|
// so each call reads the current process.env.
|
|
|
|
it("parses env vars and applies Zod schema correctly", async () => {
|
|
// loadEnv reads process.env; vitest.config.ts injects test env vars.
|
|
// We verify the schema parses them correctly rather than testing defaults
|
|
// (which requires a clean env that vitest's env injection prevents).
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
const env = loadEnv();
|
|
// Verify types are correct after Zod parsing
|
|
expect(typeof env.PORT).toBe("number");
|
|
expect(typeof env.AUTH_ENABLED).toBe("boolean");
|
|
expect(typeof env.DEFAULT_USERNAME).toBe("string");
|
|
expect(typeof env.DEFAULT_PASSWORD).toBe("string");
|
|
expect(["local", "s3"]).toContain(env.STORAGE_MODE);
|
|
expect(typeof env.FILE_MAX_AGE_HOURS).toBe("number");
|
|
expect(typeof env.CLEANUP_INTERVAL_MINUTES).toBe("number");
|
|
expect(typeof env.MAX_UPLOAD_SIZE_MB).toBe("number");
|
|
expect(typeof env.MAX_BATCH_SIZE).toBe("number");
|
|
expect(typeof env.CONCURRENT_JOBS).toBe("number");
|
|
expect(typeof env.MAX_MEGAPIXELS).toBe("number");
|
|
expect(typeof env.RATE_LIMIT_PER_MIN).toBe("number");
|
|
expect(typeof env.SKIP_MUST_CHANGE_PASSWORD).toBe("boolean");
|
|
expect(typeof env.DB_PATH).toBe("string");
|
|
expect(typeof env.WORKSPACE_PATH).toBe("string");
|
|
expect(["light", "dark"]).toContain(env.DEFAULT_THEME);
|
|
expect(typeof env.DEFAULT_LOCALE).toBe("string");
|
|
expect(typeof env.APP_NAME).toBe("string");
|
|
});
|
|
|
|
it("parses custom PORT as a number via coercion", async () => {
|
|
process.env.PORT = "8080";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
expect(loadEnv().PORT).toBe(8080);
|
|
});
|
|
|
|
it("coerces string 'true' for AUTH_ENABLED to boolean true", async () => {
|
|
process.env.AUTH_ENABLED = "true";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
expect(loadEnv().AUTH_ENABLED).toBe(true);
|
|
});
|
|
|
|
it("coerces string 'false' for AUTH_ENABLED to boolean false", async () => {
|
|
process.env.AUTH_ENABLED = "false";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
expect(loadEnv().AUTH_ENABLED).toBe(false);
|
|
});
|
|
|
|
it("rejects AUTH_ENABLED with a non-enum value", async () => {
|
|
process.env.AUTH_ENABLED = "yes";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
expect(() => loadEnv()).toThrow();
|
|
});
|
|
|
|
it("rejects STORAGE_MODE with an invalid enum value", async () => {
|
|
process.env.STORAGE_MODE = "gcs";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
expect(() => loadEnv()).toThrow();
|
|
});
|
|
|
|
it("rejects DEFAULT_THEME with an invalid enum value", async () => {
|
|
process.env.DEFAULT_THEME = "solarized";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
expect(() => loadEnv()).toThrow();
|
|
});
|
|
|
|
it("coerces numeric strings for MAX_MEGAPIXELS", async () => {
|
|
process.env.MAX_MEGAPIXELS = "50";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
expect(loadEnv().MAX_MEGAPIXELS).toBe(50);
|
|
});
|
|
|
|
it("coerces floating point string for FILE_MAX_AGE_HOURS", async () => {
|
|
process.env.FILE_MAX_AGE_HOURS = "2.5";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
expect(loadEnv().FILE_MAX_AGE_HOURS).toBe(2.5);
|
|
});
|
|
|
|
it("accepts 0 for numeric fields", async () => {
|
|
process.env.PORT = "0";
|
|
process.env.CONCURRENT_JOBS = "0";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
const env = loadEnv();
|
|
expect(env.PORT).toBe(0);
|
|
expect(env.CONCURRENT_JOBS).toBe(0);
|
|
});
|
|
|
|
it("coerces negative numbers for numeric fields", async () => {
|
|
process.env.PORT = "-1";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
// zod coerce will parse to -1; there is no positive-only constraint
|
|
expect(loadEnv().PORT).toBe(-1);
|
|
});
|
|
|
|
it("accepts string values for string fields", async () => {
|
|
process.env.APP_NAME = "My Custom App";
|
|
process.env.DB_PATH = "/var/data/mydb.sqlite";
|
|
process.env.DEFAULT_LOCALE = "fr";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
const env = loadEnv();
|
|
expect(env.APP_NAME).toBe("My Custom App");
|
|
expect(env.DB_PATH).toBe("/var/data/mydb.sqlite");
|
|
expect(env.DEFAULT_LOCALE).toBe("fr");
|
|
});
|
|
|
|
it("rejects non-numeric strings for number fields", async () => {
|
|
process.env.PORT = "not_a_number";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
// z.coerce.number() produces NaN for non-numeric strings, and zod rejects NaN
|
|
expect(() => loadEnv()).toThrow();
|
|
});
|
|
|
|
it("accepts all custom values at once", async () => {
|
|
process.env.PORT = "9999";
|
|
process.env.AUTH_ENABLED = "true";
|
|
process.env.DEFAULT_USERNAME = "root";
|
|
process.env.DEFAULT_PASSWORD = "s3cret!";
|
|
process.env.STORAGE_MODE = "s3";
|
|
process.env.DEFAULT_THEME = "dark";
|
|
process.env.MAX_BATCH_SIZE = "500";
|
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
|
const env = loadEnv();
|
|
expect(env.PORT).toBe(9999);
|
|
expect(env.AUTH_ENABLED).toBe(true);
|
|
expect(env.DEFAULT_USERNAME).toBe("root");
|
|
expect(env.DEFAULT_PASSWORD).toBe("s3cret!");
|
|
expect(env.STORAGE_MODE).toBe("s3");
|
|
expect(env.DEFAULT_THEME).toBe("dark");
|
|
expect(env.MAX_BATCH_SIZE).toBe(500);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. Auth helpers (hashPassword / verifyPassword)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// These functions are pure crypto -- they do not import db or env at the module
|
|
// top level, so we can import them directly without mocking the database.
|
|
// However auth.ts does import db and env at the top level, so we need to mock those.
|
|
|
|
vi.mock("../../../apps/api/src/db/index.js", () => ({
|
|
db: {},
|
|
schema: { users: {}, sessions: {} },
|
|
}));
|
|
|
|
describe("hashPassword", () => {
|
|
let hashPassword: typeof import("../../../apps/api/src/plugins/auth.js").hashPassword;
|
|
|
|
beforeEach(async () => {
|
|
const mod = await import("../../../apps/api/src/plugins/auth.js");
|
|
hashPassword = mod.hashPassword;
|
|
});
|
|
|
|
it("produces a string in salt:hash format", async () => {
|
|
const result = await hashPassword("mypassword");
|
|
const parts = result.split(":");
|
|
expect(parts).toHaveLength(2);
|
|
expect(parts[0].length).toBeGreaterThan(0); // salt
|
|
expect(parts[1].length).toBeGreaterThan(0); // hash
|
|
});
|
|
|
|
it("salt is 32 bytes (64 hex chars)", async () => {
|
|
const result = await hashPassword("test");
|
|
const salt = result.split(":")[0];
|
|
expect(salt.length).toBe(64); // 32 bytes * 2 hex chars
|
|
// Ensure it's valid hex
|
|
expect(/^[0-9a-f]+$/.test(salt)).toBe(true);
|
|
});
|
|
|
|
it("hash (derived key) is 64 bytes (128 hex chars)", async () => {
|
|
const result = await hashPassword("test");
|
|
const hash = result.split(":")[1];
|
|
expect(hash.length).toBe(128); // 64 bytes * 2 hex chars
|
|
expect(/^[0-9a-f]+$/.test(hash)).toBe(true);
|
|
});
|
|
|
|
it("different passwords produce different hashes", async () => {
|
|
const hash1 = await hashPassword("password1");
|
|
const hash2 = await hashPassword("password2");
|
|
expect(hash1).not.toBe(hash2);
|
|
});
|
|
|
|
it("same password produces different salts (non-deterministic)", async () => {
|
|
const hash1 = await hashPassword("samepassword");
|
|
const hash2 = await hashPassword("samepassword");
|
|
const salt1 = hash1.split(":")[0];
|
|
const salt2 = hash2.split(":")[0];
|
|
expect(salt1).not.toBe(salt2);
|
|
});
|
|
|
|
it("same password produces different derived keys due to different salts", async () => {
|
|
const hash1 = await hashPassword("samepassword");
|
|
const hash2 = await hashPassword("samepassword");
|
|
const derived1 = hash1.split(":")[1];
|
|
const derived2 = hash2.split(":")[1];
|
|
expect(derived1).not.toBe(derived2);
|
|
});
|
|
|
|
it("handles empty string password", async () => {
|
|
const result = await hashPassword("");
|
|
expect(result).toContain(":");
|
|
const parts = result.split(":");
|
|
expect(parts[0].length).toBe(64);
|
|
expect(parts[1].length).toBe(128);
|
|
});
|
|
|
|
it("handles very long password", async () => {
|
|
const longPw = "a".repeat(10_000);
|
|
const result = await hashPassword(longPw);
|
|
expect(result).toContain(":");
|
|
const parts = result.split(":");
|
|
expect(parts[0].length).toBe(64);
|
|
expect(parts[1].length).toBe(128);
|
|
});
|
|
|
|
it("handles unicode password", async () => {
|
|
const result = await hashPassword("\u{1F600}\u{1F680}\u4F60\u597D");
|
|
expect(result).toContain(":");
|
|
const parts = result.split(":");
|
|
expect(parts).toHaveLength(2);
|
|
});
|
|
|
|
it("handles password with colons (does not break salt:hash parsing)", async () => {
|
|
const result = await hashPassword("pass:with:colons");
|
|
// The output format is salt:hash -- salt and hash are hex so they cannot contain colons
|
|
const parts = result.split(":");
|
|
expect(parts).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("verifyPassword", () => {
|
|
let hashPassword: typeof import("../../../apps/api/src/plugins/auth.js").hashPassword;
|
|
let verifyPassword: typeof import("../../../apps/api/src/plugins/auth.js").verifyPassword;
|
|
|
|
beforeEach(async () => {
|
|
const mod = await import("../../../apps/api/src/plugins/auth.js");
|
|
hashPassword = mod.hashPassword;
|
|
verifyPassword = mod.verifyPassword;
|
|
});
|
|
|
|
it("returns true for the correct password", async () => {
|
|
const stored = await hashPassword("correcthorse");
|
|
expect(await verifyPassword("correcthorse", stored)).toBe(true);
|
|
});
|
|
|
|
it("returns false for the wrong password", async () => {
|
|
const stored = await hashPassword("correcthorse");
|
|
expect(await verifyPassword("wronghorse", stored)).toBe(false);
|
|
});
|
|
|
|
it("returns false for empty password when hash was non-empty", async () => {
|
|
const stored = await hashPassword("realpassword");
|
|
expect(await verifyPassword("", stored)).toBe(false);
|
|
});
|
|
|
|
it("returns true for empty password when hash was from empty password", async () => {
|
|
const stored = await hashPassword("");
|
|
expect(await verifyPassword("", stored)).toBe(true);
|
|
});
|
|
|
|
it("returns false for a stored hash with no colon separator", async () => {
|
|
expect(await verifyPassword("anything", "nocolonhere")).toBe(false);
|
|
});
|
|
|
|
it("returns false for an empty stored hash string", async () => {
|
|
expect(await verifyPassword("anything", "")).toBe(false);
|
|
});
|
|
|
|
it("returns false for stored hash that is just a colon", async () => {
|
|
expect(await verifyPassword("anything", ":")).toBe(false);
|
|
});
|
|
|
|
it("returns false when salt is present but hash part is empty", async () => {
|
|
expect(await verifyPassword("test", "abcd1234:")).toBe(false);
|
|
});
|
|
|
|
it("returns false when hash part is present but salt is empty", async () => {
|
|
expect(await verifyPassword("test", ":abcd1234")).toBe(false);
|
|
});
|
|
|
|
it("returns false for a corrupted (truncated) hash", async () => {
|
|
const stored = await hashPassword("mypass");
|
|
// Truncate the hash part
|
|
const truncated = stored.substring(0, stored.indexOf(":") + 5);
|
|
expect(await verifyPassword("mypass", truncated)).toBe(false);
|
|
});
|
|
|
|
it("returns false for a valid salt with wrong hash bytes", async () => {
|
|
const stored = await hashPassword("mypass");
|
|
const salt = stored.split(":")[0];
|
|
// Replace the hash with a different valid hex string of the same length
|
|
const fakeHash = "ff".repeat(64);
|
|
expect(await verifyPassword("mypass", `${salt}:${fakeHash}`)).toBe(false);
|
|
});
|
|
|
|
it("handles unicode passwords in verification", async () => {
|
|
const pw = "\u00E9\u00E8\u00EA\u00EB"; // accented characters
|
|
const stored = await hashPassword(pw);
|
|
expect(await verifyPassword(pw, stored)).toBe(true);
|
|
expect(await verifyPassword("eeee", stored)).toBe(false);
|
|
});
|
|
|
|
it("is case-sensitive", async () => {
|
|
const stored = await hashPassword("Password");
|
|
expect(await verifyPassword("Password", stored)).toBe(true);
|
|
expect(await verifyPassword("password", stored)).toBe(false);
|
|
expect(await verifyPassword("PASSWORD", stored)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. sanitizeFilename
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The function is duplicated across multiple route files. Since it is not exported,
|
|
// we replicate the exact logic here and test it directly. This tests the BEHAVIOR
|
|
// so if any of the copies drift, these tests document the expected contract.
|
|
|
|
function sanitizeFilename(raw: string): string {
|
|
let name = basename(raw);
|
|
name = name.replace(/\.\./g, "");
|
|
name = name.replace(/\0/g, "");
|
|
if (!name || name === "." || name === "..") {
|
|
name = "upload";
|
|
}
|
|
return name;
|
|
}
|
|
|
|
describe("sanitizeFilename", () => {
|
|
// -- Path traversal attacks -----------------------------------------------
|
|
|
|
it("strips directory traversal ../../etc/passwd", () => {
|
|
const result = sanitizeFilename("../../etc/passwd");
|
|
expect(result).toBe("passwd");
|
|
expect(result).not.toContain("..");
|
|
expect(result).not.toContain("/");
|
|
});
|
|
|
|
it("strips more deeply nested traversal", () => {
|
|
const result = sanitizeFilename("../../../../../../../etc/shadow");
|
|
expect(result).toBe("shadow");
|
|
});
|
|
|
|
it("strips Windows-style backslash traversal", () => {
|
|
const result = sanitizeFilename("..\\..\\Windows\\System32\\config\\SAM");
|
|
// basename on POSIX treats backslashes literally; on Windows it would strip
|
|
// Either way the result should not contain path separators
|
|
expect(result).not.toContain("/");
|
|
});
|
|
|
|
it("strips traversal with URL encoding (literal %2e%2e)", () => {
|
|
// basename sees this as a literal filename with percent signs
|
|
const result = sanitizeFilename("%2e%2e/%2e%2e/etc/passwd");
|
|
expect(result).toBe("passwd");
|
|
});
|
|
|
|
// -- Null bytes -----------------------------------------------------------
|
|
|
|
it("removes null bytes from filename", () => {
|
|
const result = sanitizeFilename("image\0.png");
|
|
expect(result).toBe("image.png");
|
|
expect(result).not.toContain("\0");
|
|
});
|
|
|
|
it("removes multiple null bytes", () => {
|
|
const result = sanitizeFilename("\0\0evil\0\0.exe\0");
|
|
expect(result).not.toContain("\0");
|
|
});
|
|
|
|
it("falls back when filename is only null bytes", () => {
|
|
const result = sanitizeFilename("\0\0\0");
|
|
expect(result).toBe("upload");
|
|
});
|
|
|
|
// -- Empty / degenerate inputs --------------------------------------------
|
|
|
|
it("returns fallback for empty string", () => {
|
|
const result = sanitizeFilename("");
|
|
expect(result).toBe("upload");
|
|
});
|
|
|
|
it("returns fallback for single dot", () => {
|
|
const result = sanitizeFilename(".");
|
|
expect(result).toBe("upload");
|
|
});
|
|
|
|
it("returns fallback for double dot", () => {
|
|
const result = sanitizeFilename("..");
|
|
expect(result).toBe("upload");
|
|
});
|
|
|
|
it("returns fallback for triple dots (.. removal leaves .)", () => {
|
|
// "..." -> remove ".." -> "." -> fallback
|
|
const result = sanitizeFilename("...");
|
|
expect(result).toBe("upload");
|
|
});
|
|
|
|
it("returns fallback for four dots (.. removal leaves empty)", () => {
|
|
// "...." -> remove all ".." occurrences -> "" -> fallback
|
|
const result = sanitizeFilename("....");
|
|
expect(result).toBe("upload");
|
|
});
|
|
|
|
it("returns fallback for slash only", () => {
|
|
const result = sanitizeFilename("/");
|
|
expect(result).toBe("upload");
|
|
});
|
|
|
|
// -- Normal filenames preserved -------------------------------------------
|
|
|
|
it("preserves a normal filename", () => {
|
|
expect(sanitizeFilename("photo.png")).toBe("photo.png");
|
|
});
|
|
|
|
it("preserves a filename with spaces", () => {
|
|
expect(sanitizeFilename("my photo 2024.jpg")).toBe("my photo 2024.jpg");
|
|
});
|
|
|
|
it("preserves a filename with dashes and underscores", () => {
|
|
expect(sanitizeFilename("my-photo_v2.webp")).toBe("my-photo_v2.webp");
|
|
});
|
|
|
|
it("preserves a dotfile (starts with single dot)", () => {
|
|
expect(sanitizeFilename(".gitignore")).toBe(".gitignore");
|
|
});
|
|
|
|
it("preserves filename with multiple extensions", () => {
|
|
expect(sanitizeFilename("archive.tar.gz")).toBe("archive.tar.gz");
|
|
});
|
|
|
|
// -- Unicode filenames ----------------------------------------------------
|
|
|
|
it("preserves CJK characters", () => {
|
|
expect(sanitizeFilename("\u5199\u771F.png")).toBe("\u5199\u771F.png");
|
|
});
|
|
|
|
it("preserves emoji in filenames", () => {
|
|
expect(sanitizeFilename("\u{1F600}photo.jpg")).toBe("\u{1F600}photo.jpg");
|
|
});
|
|
|
|
it("preserves Arabic script", () => {
|
|
expect(sanitizeFilename("\u0635\u0648\u0631\u0629.png")).toBe("\u0635\u0648\u0631\u0629.png");
|
|
});
|
|
|
|
it("preserves accented Latin characters", () => {
|
|
expect(sanitizeFilename("caf\u00E9.png")).toBe("caf\u00E9.png");
|
|
});
|
|
|
|
// -- Tricky edge cases ----------------------------------------------------
|
|
|
|
it("extracts basename from a full path", () => {
|
|
expect(sanitizeFilename("/usr/local/bin/image.png")).toBe("image.png");
|
|
});
|
|
|
|
it("handles filename consisting only of spaces", () => {
|
|
const result = sanitizeFilename(" ");
|
|
// basename(" ") returns " " which is truthy and not "." or ".."
|
|
expect(result).toBe(" ");
|
|
});
|
|
|
|
it("strips double dots embedded in a filename but keeps the rest", () => {
|
|
// "my..file..name.png" -> remove ".." -> "myfilename.png"
|
|
expect(sanitizeFilename("my..file..name.png")).toBe("myfilename.png");
|
|
});
|
|
|
|
it("handles very long filenames", () => {
|
|
const longName = "a".repeat(500) + ".png";
|
|
expect(sanitizeFilename(longName)).toBe(longName);
|
|
});
|
|
|
|
it("handles filename with only extension", () => {
|
|
expect(sanitizeFilename(".png")).toBe(".png");
|
|
});
|
|
|
|
it("handles directory path with trailing slash", () => {
|
|
// basename("/foo/bar/") returns "bar" on POSIX
|
|
expect(sanitizeFilename("/foo/bar/")).toBe("bar");
|
|
});
|
|
});
|