test: add release v2 mega QA seeder

This commit is contained in:
ajnart committed 2026-09-01 11:01:01 +02:00
1 parent ac62e5bfd6
commit 2a4658d255
10 files changed
+2802

No files matched your search

+2
View File
@@ -44,6 +44,8 @@
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh",
"qa:release-v2:fixtures": "tsx scripts/release-v2-qa/fixture-server.mts",
"qa:release-v2:seed": "tsx scripts/release-v2-qa/seed.mts",
"release": "semantic-release",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
+48
View File
@@ -0,0 +1,48 @@
# Release-v2 mega QA seeder
This test-only harness creates deterministic users, boards, layouts, containers, integrations, Custom Widgets, and all current widget kinds for release-v2 browser QA. It does not change production migrations or the normal demo seed.
## Requirements
- Run the normal database migrations and base seed first.
- Use an isolated SQLite database through `DB_DRIVER=better-sqlite3` and an absolute `DB_URL`.
- Start the fixture service and provide its loopback URL as `QA_FIXTURE_URL`.
- Put the disposable QA password in a file and provide its path through `QA_PASSWORD_FILE`. The password is hashed and never written to the fixture manifest.
- Set the exact profile flags shown below. The seeder rejects missing, malformed, or mismatched values before changing the database.
## Commands
Start the deterministic fixture service:
```bash
pnpm qa:release-v2:fixtures -- --ready-file /tmp/release-v2-fixture-ready.json
```
Seed a populated writable fixture:
```bash
DB_DRIVER=better-sqlite3 \
DB_URL=/absolute/path/to/qa.sqlite \
QA_FIXTURE_URL=http://127.0.0.1:<fixture-port> \
QA_PASSWORD_FILE=/absolute/path/to/qa-password \
DEMO_MODE=true \
DEMO_READ_ONLY=false \
UNSAFE_ENABLE_MOCK_INTEGRATION=true \
pnpm qa:release-v2:seed -- \
--profile main-writable \
--output /absolute/path/to/output \
--reset
```
Supported profiles:
| Profile | `DEMO_MODE` | `DEMO_READ_ONLY` | `UNSAFE_ENABLE_MOCK_INTEGRATION` |
| ------------------ | ----------- | ---------------- | -------------------------------- |
| `main-writable` | `true` | `false` | `true` |
| `main-readonly` | `true` | `true` | `true` |
| `onboarding-fresh` | `false` | `false` | `true` |
| `degraded` | `true` | `false` | `true` |
`onboarding-fresh` requires no QA password and removes only resources with fixed `qa-v2-*` identifiers. Populated profiles are idempotent. `--reset` remains narrowly scoped to those deterministic identifiers.
The generated `fixture-manifest.json` records the checkout SHA, profile, non-secret persona login usernames, boards, layouts, item counts, expected permissions, fixture URL, and runtime flags. It never contains a password, password hash, token, encryption key, or Custom Widget secret.
+349
View File
@@ -0,0 +1,349 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import { readFile, mkdir, open, rename, rm } from "node:fs/promises";
import path from "node:path";
import { validateLoopbackHost } from "./safety.mts";
const DEFAULT_HOST = "127.0.0.1";
const MAX_BODY_BYTES = 2 * 1024 * 1024;
const MAX_SLOW_MS = 30_000;
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const videoPath = path.resolve(
import.meta.dirname,
"../../apps/docs/src/components/pages/home/drag-and-drop/showcase-dark.mp4",
);
const createWav = () => {
const sampleRate = 8_000;
const sampleCount = sampleRate / 4;
const dataLength = sampleCount * 2;
const buffer = Buffer.alloc(44 + dataLength);
buffer.write("RIFF", 0);
buffer.writeUInt32LE(36 + dataLength, 4);
buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20);
buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(sampleRate, 24);
buffer.writeUInt32LE(sampleRate * 2, 28);
buffer.writeUInt16LE(2, 32);
buffer.writeUInt16LE(16, 34);
buffer.write("data", 36);
buffer.writeUInt32LE(dataLength, 40);
for (let index = 0; index < sampleCount; index += 1) {
const value = Math.sin((2 * Math.PI * 440 * index) / sampleRate) * 0.2;
buffer.writeInt16LE(Math.round(value * 32_767), 44 + index * 2);
}
return buffer;
};
const wav = createWav();
const customWidgetPayload = {
title: "Release v2 QA fixture",
status: "ok",
items: [
{ id: "alpha", label: "Alpha", value: 42 },
{ id: "beta", label: "Beta", value: 7 },
],
};
const parseArgs = () => {
const args = process.argv.slice(2).filter((argument) => argument !== "--");
let host = DEFAULT_HOST;
let port = 0;
let readyFile: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
const value = args[index + 1];
if (argument === "--host" && value) {
host = value;
index += 1;
continue;
}
if (argument === "--port" && value) {
port = Number(value);
index += 1;
continue;
}
if (argument === "--ready-file" && value) {
readyFile = path.resolve(value);
index += 1;
continue;
}
throw new Error(`Unknown or incomplete argument: ${argument ?? "<missing>"}`);
}
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
throw new Error("--port must be an integer between 0 and 65535");
}
return { host: validateLoopbackHost(host), port, readyFile };
};
const setCommonHeaders = (response: ServerResponse) => {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, X-QA-Fixture");
response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-QA-Fixture", "release-v2");
};
const send = (response: ServerResponse, status: number, contentType: string, body: string | Buffer) => {
setCommonHeaders(response);
response.writeHead(status, { "Content-Type": contentType });
response.end(body);
};
const sendJson = (response: ServerResponse, status: number, value: unknown) => {
send(response, status, "application/json; charset=utf-8", `${JSON.stringify(value)}\n`);
};
const readBody = async (request: IncomingMessage) => {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > MAX_BODY_BYTES) throw new Error("UPLOAD_TOO_LARGE");
chunks.push(buffer);
}
return Buffer.concat(chunks);
};
const handleRequest = async (request: IncomingMessage, response: ServerResponse) => {
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? DEFAULT_HOST}`);
const method = request.method ?? "GET";
if (method === "OPTIONS") {
setCommonHeaders(response);
response.writeHead(204);
response.end();
return;
}
if (url.pathname === "/health") {
sendJson(response, 200, { status: "ok", fixture: "release-v2-qa" });
return;
}
if (url.pathname === "/json") {
sendJson(response, 200, {
id: "qa-json-001",
name: "Release v2 deterministic JSON",
active: true,
count: 3,
tags: ["homarr", "release-v2", "qa"],
nested: { score: 99, nullable: null },
});
return;
}
if (url.pathname === "/api/qa/custom-widget") {
sendJson(response, 200, customWidgetPayload);
return;
}
if (url.pathname === "/rss") {
send(
response,
200,
"application/rss+xml; charset=utf-8",
`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>Release v2 QA</title><link>https://example.invalid/qa</link><description>Deterministic feed</description><item><guid>qa-entry-1</guid><title>Alpha release note</title><link>https://example.invalid/qa/alpha</link><pubDate>Thu, 01 Jan 2026 00:00:00 GMT</pubDate><description>Stable fixture entry</description></item><item><guid>qa-entry-2</guid><title>Beta release note</title><link>https://example.invalid/qa/beta</link><pubDate>Fri, 02 Jan 2026 00:00:00 GMT</pubDate><description>Second stable fixture entry</description></item></channel></rss>\n`,
);
return;
}
if (url.pathname === "/media/image.png") {
send(response, 200, "image/png", png);
return;
}
if (url.pathname === "/media/image.svg") {
send(
response,
200,
"image/svg+xml; charset=utf-8",
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180"><rect width="320" height="180" fill="#111827"/><circle cx="72" cy="90" r="40" fill="#38bdf8"/><text x="132" y="98" fill="#f8fafc" font-family="sans-serif" font-size="22">Release v2 QA</text></svg>\n',
);
return;
}
if (url.pathname === "/media/audio.wav") {
send(response, 200, "audio/wav", wav);
return;
}
if (url.pathname === "/media/video.mp4") {
const video = await readFile(videoPath);
const range = request.headers.range;
setCommonHeaders(response);
response.setHeader("Accept-Ranges", "bytes");
if (!range) {
response.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": video.length });
response.end(method === "HEAD" ? undefined : video);
return;
}
const match = /^bytes=(\d+)-(\d*)$/.exec(range);
if (!match) {
response.writeHead(416, { "Content-Range": `bytes */${video.length}` });
response.end();
return;
}
const start = Number(match[1]);
const requestedEnd = match[2] ? Number(match[2]) : video.length - 1;
const end = Math.min(requestedEnd, video.length - 1);
if (start > end || start >= video.length) {
response.writeHead(416, { "Content-Range": `bytes */${video.length}` });
response.end();
return;
}
const body = video.subarray(start, end + 1);
response.writeHead(206, {
"Content-Type": "video/mp4",
"Content-Length": body.length,
"Content-Range": `bytes ${start}-${end}/${video.length}`,
});
response.end(method === "HEAD" ? undefined : body);
return;
}
if (url.pathname === "/iframe") {
send(
response,
200,
"text/html; charset=utf-8",
'<!doctype html><html><head><meta charset="utf-8"><title>Release v2 QA iframe</title></head><body><main data-qa-fixture="iframe"><h1>Deterministic iframe</h1><p>release-v2-qa</p></main></body></html>\n',
);
return;
}
if (url.pathname === "/api/qa/download/sample.txt") {
setCommonHeaders(response);
response.writeHead(200, {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": 'attachment; filename="release-v2-qa-sample.txt"',
});
response.end("Release v2 QA deterministic download\nline=2\n");
return;
}
if (url.pathname === "/upload") {
if (method !== "POST" && method !== "PUT") {
sendJson(response, 405, { error: "METHOD_NOT_ALLOWED", allowed: ["POST", "PUT"] });
return;
}
try {
const body = await readBody(request);
sendJson(response, 200, {
uploaded: true,
method,
bytes: body.length,
contentType: request.headers["content-type"] ?? null,
sha256: createHash("sha256").update(body).digest("hex"),
});
} catch (error) {
if (error instanceof Error && error.message === "UPLOAD_TOO_LARGE") {
sendJson(response, 413, { error: "UPLOAD_TOO_LARGE", maxBytes: MAX_BODY_BYTES });
return;
}
throw error;
}
return;
}
if (url.pathname === "/empty") {
setCommonHeaders(response);
response.writeHead(204);
response.end();
return;
}
if (url.pathname === "/malformed" || url.pathname === "/malformed/json") {
send(response, 200, "application/json; charset=utf-8", '{"status":"broken","items":[1,2,}\n');
return;
}
if (url.pathname === "/slow") {
const requestedMs = Number(url.searchParams.get("ms") ?? "1500");
const delayMs = Number.isFinite(requestedMs) ? Math.max(0, Math.min(Math.trunc(requestedMs), MAX_SLOW_MS)) : 1500;
await new Promise((resolve) => setTimeout(resolve, delayMs));
sendJson(response, 200, { status: "ok", delayedMs: delayMs });
return;
}
if (url.pathname === "/error" || url.pathname.startsWith("/error/")) {
const pathStatus = url.pathname.split("/")[2];
const requestedStatus = Number(url.searchParams.get("status") ?? pathStatus ?? "503");
const status =
Number.isInteger(requestedStatus) && requestedStatus >= 400 && requestedStatus <= 599 ? requestedStatus : 503;
sendJson(response, status, { error: "QA_CONFIGURED_HTTP_ERROR", status });
return;
}
sendJson(response, 404, { error: "NOT_FOUND", path: url.pathname });
};
const writeJsonAtomic = async (filePath: string, value: unknown) => {
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
const handle = await open(
temporaryPath,
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
0o600,
);
try {
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
await handle.sync();
} finally {
await handle.close();
}
try {
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true });
throw error;
}
};
const main = async () => {
const options = parseArgs();
const server = createServer((request, response) => {
void handleRequest(request, response).catch((error: unknown) => {
console.error(error);
if (!response.headersSent) sendJson(response, 500, { error: "FIXTURE_INTERNAL_ERROR" });
else response.destroy();
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(options.port, options.host, resolve);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("Fixture server did not expose a TCP address");
const urlHost = options.host.includes(":") ? `[${options.host}]` : options.host;
const url = `http://${urlHost}:${address.port}`;
const ready = { schemaVersion: 1, pid: process.pid, host: options.host, port: address.port, url };
if (options.readyFile) await writeJsonAtomic(options.readyFile, ready);
process.stdout.write(`${JSON.stringify(ready)}\n`);
const shutdown = () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5_000).unref();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
};
await main();
+152
View File
@@ -0,0 +1,152 @@
import type { LayoutRole } from "../../packages/definitions/index.ts";
import { getBoardLaneColumnCount, getRootSectionLane } from "../../packages/definitions/index.ts";
interface GeometryLayout {
id: string;
boardId: string;
columnCount: number;
leftGutterColumnCount: number;
rightGutterColumnCount: number;
role: LayoutRole;
}
interface GeometrySection {
id: string;
boardId: string;
kind: "empty" | "container";
xOffset: number | null;
}
interface GeometryPlacement {
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
width: number;
height: number;
}
interface GeometrySectionLayout extends GeometryPlacement {
parentSectionId: string | null;
}
interface GeometryItemLayout extends GeometryPlacement {
itemId: string;
boardId: string;
}
export interface FixtureGeometry {
layouts: GeometryLayout[];
sections: GeometrySection[];
sectionLayouts: GeometrySectionLayout[];
itemLayouts: GeometryItemLayout[];
}
const placementIsValid = (placement: GeometryPlacement) =>
Number.isInteger(placement.xOffset) &&
Number.isInteger(placement.yOffset) &&
Number.isInteger(placement.width) &&
Number.isInteger(placement.height) &&
placement.xOffset >= 0 &&
placement.yOffset >= 0 &&
placement.width >= 1 &&
placement.height >= 1;
export const findFixtureGeometryErrors = (geometry: FixtureGeometry) => {
const errors: string[] = [];
const layoutsById = new Map(geometry.layouts.map((layout) => [layout.id, layout]));
const sectionsById = new Map(geometry.sections.map((section) => [section.id, section]));
const sectionLayoutsByKey = new Map<string, GeometrySectionLayout>();
for (const sectionLayout of geometry.sectionLayouts) {
const key = `${sectionLayout.layoutId}\0${sectionLayout.sectionId}`;
if (sectionLayoutsByKey.has(key)) {
errors.push(`container ${sectionLayout.sectionId} has duplicate placement in layout ${sectionLayout.layoutId}`);
continue;
}
sectionLayoutsByKey.set(key, sectionLayout);
}
const parentCapacity = (
layout: GeometryLayout,
parentSection: GeometrySection,
): { columns: number; kind: "lane" | "container" } | undefined => {
if (parentSection.kind === "empty") {
return {
columns: getBoardLaneColumnCount(layout, getRootSectionLane(parentSection.xOffset)),
kind: "lane",
};
}
const parentLayout = sectionLayoutsByKey.get(`${layout.id}\0${parentSection.id}`);
if (!parentLayout) return undefined;
return { columns: parentLayout.width, kind: "container" };
};
for (const sectionLayout of geometry.sectionLayouts) {
const layout = layoutsById.get(sectionLayout.layoutId);
const section = sectionsById.get(sectionLayout.sectionId);
const parent = sectionLayout.parentSectionId ? sectionsById.get(sectionLayout.parentSectionId) : undefined;
if (!layout || !section || !parent) {
errors.push(
`container ${sectionLayout.sectionId} in layout ${sectionLayout.layoutId} has unknown geometry references`,
);
continue;
}
if (section.kind !== "container" || section.boardId !== layout.boardId || parent.boardId !== layout.boardId) {
errors.push(`container ${section.id} in layout ${layout.id} crosses a board or root boundary`);
continue;
}
if (!placementIsValid(sectionLayout)) {
errors.push(`container ${section.id} in layout ${layout.id} has an invalid placement`);
continue;
}
const capacity = parentCapacity(layout, parent);
if (!capacity) {
errors.push(`container ${section.id} in layout ${layout.id} has an unplaced parent container`);
} else if (sectionLayout.xOffset + sectionLayout.width > capacity.columns) {
errors.push(
`container ${section.id} in layout ${layout.id} exceeds its ${capacity.columns}-column parent ${capacity.kind}`,
);
}
const visited = new Set<string>();
let currentSectionId: string | null = section.id;
while (currentSectionId) {
if (visited.has(currentSectionId)) {
errors.push(`container cycle detected in layout ${layout.id} at ${currentSectionId}`);
break;
}
visited.add(currentSectionId);
const current = sectionsById.get(currentSectionId);
if (!current || current.kind === "empty") break;
currentSectionId = sectionLayoutsByKey.get(`${layout.id}\0${currentSectionId}`)?.parentSectionId ?? null;
}
}
for (const itemLayout of geometry.itemLayouts) {
const layout = layoutsById.get(itemLayout.layoutId);
const parent = sectionsById.get(itemLayout.sectionId);
if (!layout || !parent || itemLayout.boardId !== layout.boardId || parent.boardId !== layout.boardId) {
errors.push(`item ${itemLayout.itemId} in layout ${itemLayout.layoutId} crosses a board or section boundary`);
continue;
}
if (!placementIsValid(itemLayout)) {
errors.push(`item ${itemLayout.itemId} in layout ${layout.id} has an invalid placement`);
continue;
}
const capacity = parentCapacity(layout, parent);
if (!capacity) {
errors.push(`item ${itemLayout.itemId} in layout ${layout.id} has an unplaced parent container`);
continue;
}
if (itemLayout.xOffset + itemLayout.width > capacity.columns) {
errors.push(
`item ${itemLayout.itemId} in layout ${layout.id} exceeds its ${capacity.columns}-column parent ${capacity.kind}`,
);
}
}
return errors;
};
+119
View File
@@ -0,0 +1,119 @@
// @vitest-environment node
import { describe, expect, test } from "vitest";
import type { FixtureGeometry } from "./geometry.mts";
import { findFixtureGeometryErrors } from "./geometry.mts";
const baseGeometry = (): FixtureGeometry => ({
layouts: [
{
id: "layout-mobile",
boardId: "board",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
role: "mobile",
},
],
sections: [
{ id: "root", boardId: "board", kind: "empty", xOffset: 0 },
{ id: "level-1", boardId: "board", kind: "container", xOffset: null },
{ id: "level-2", boardId: "board", kind: "container", xOffset: null },
{ id: "level-3", boardId: "board", kind: "container", xOffset: null },
],
sectionLayouts: [
{
sectionId: "level-1",
layoutId: "layout-mobile",
parentSectionId: "root",
xOffset: 0,
yOffset: 0,
width: 3,
height: 6,
},
{
sectionId: "level-2",
layoutId: "layout-mobile",
parentSectionId: "level-1",
xOffset: 0,
yOffset: 0,
width: 2,
height: 5,
},
{
sectionId: "level-3",
layoutId: "layout-mobile",
parentSectionId: "level-2",
xOffset: 0,
yOffset: 0,
width: 1,
height: 4,
},
],
itemLayouts: [
{
itemId: "nested-item",
boardId: "board",
sectionId: "level-3",
layoutId: "layout-mobile",
xOffset: 0,
yOffset: 0,
width: 1,
height: 2,
},
],
});
describe("release-v2 QA fixture geometry", () => {
test("rejects an item that fits total columns but overflows the gutter-reduced main lane", () => {
const geometry = baseGeometry();
geometry.layouts[0] = {
id: "layout-mobile",
boardId: "board",
role: "base",
columnCount: 12,
leftGutterColumnCount: 2,
rightGutterColumnCount: 2,
};
geometry.sections = [{ id: "root", boardId: "board", kind: "empty", xOffset: 0 }];
geometry.sectionLayouts = [];
geometry.itemLayouts = [
{
itemId: "gutter-overflow",
boardId: "board",
sectionId: "root",
layoutId: "layout-mobile",
xOffset: 0,
yOffset: 0,
width: 9,
height: 1,
},
];
expect(findFixtureGeometryErrors(geometry)).toContain(
"item gutter-overflow in layout layout-mobile exceeds its 8-column parent lane",
);
});
test("accepts valid three-level mobile nesting and rejects a child wider than its container", () => {
const geometry = baseGeometry();
expect(findFixtureGeometryErrors(geometry)).toEqual([]);
const nestedItem = geometry.itemLayouts.at(0);
if (!nestedItem) throw new Error("Missing nested fixture item");
nestedItem.width = 2;
expect(findFixtureGeometryErrors(geometry)).toContain(
"item nested-item in layout layout-mobile exceeds its 1-column parent container",
);
});
test("rejects recursive container parent cycles", () => {
const geometry = baseGeometry();
const firstSection = geometry.sectionLayouts.at(0);
if (!firstSection) throw new Error("Missing nested fixture section");
firstSection.parentSectionId = "level-3";
expect(findFixtureGeometryErrors(geometry).some((error) => error.includes("container cycle"))).toBe(true);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { spawnSync } from "node:child_process";
const fullShaPattern = /^[0-9a-f]{40}$/iu;
export const releaseV2QaProfiles = ["main-writable", "main-readonly", "onboarding-fresh", "degraded"] as const;
export type ReleaseV2QaProfile = (typeof releaseV2QaProfiles)[number];
export interface ReleaseV2QaProfileFlags {
demoMode: boolean;
demoReadOnly: boolean;
unsafeMockIntegration: boolean;
}
const expectedProfileFlags: Record<ReleaseV2QaProfile, ReleaseV2QaProfileFlags> = {
"main-writable": { demoMode: true, demoReadOnly: false, unsafeMockIntegration: true },
"main-readonly": { demoMode: true, demoReadOnly: true, unsafeMockIntegration: true },
"onboarding-fresh": { demoMode: false, demoReadOnly: false, unsafeMockIntegration: true },
degraded: { demoMode: true, demoReadOnly: false, unsafeMockIntegration: true },
};
const profileFlagEnvironmentNames = {
demoMode: "DEMO_MODE",
demoReadOnly: "DEMO_READ_ONLY",
unsafeMockIntegration: "UNSAFE_ENABLE_MOCK_INTEGRATION",
} as const satisfies Record<keyof ReleaseV2QaProfileFlags, string>;
const parseStrictBoolean = (value: string | undefined) => {
if (value === "true") return true;
if (value === "false") return false;
return null;
};
export const assertReleaseV2QaProfileEnvironment = (
profile: ReleaseV2QaProfile,
environment: NodeJS.ProcessEnv,
): ReleaseV2QaProfileFlags => {
const expected = expectedProfileFlags[profile];
const invalidFlags: string[] = [];
for (const [flag, environmentName] of Object.entries(profileFlagEnvironmentNames) as Array<
[keyof ReleaseV2QaProfileFlags, string]
>) {
const expectedValue = expected[flag];
const actualValue = parseStrictBoolean(environment[environmentName]);
if (actualValue !== expectedValue) invalidFlags.push(`${environmentName} (expected ${String(expectedValue)})`);
}
if (invalidFlags.length > 0) {
throw new Error(`QA profile '${profile}' has invalid or missing environment flags: ${invalidFlags.join(", ")}`);
}
return { ...expected };
};
export const validateCandidateSha = (candidateSha: string, checkoutSha: string) => {
if (!fullShaPattern.test(candidateSha)) {
throw new Error("QA candidate must be a full 40-character Git SHA");
}
if (!fullShaPattern.test(checkoutSha)) {
throw new Error("The checked-out HEAD did not resolve to a full 40-character Git SHA");
}
if (candidateSha.toLowerCase() !== checkoutSha.toLowerCase()) {
throw new Error("QA candidate does not match the checked-out HEAD");
}
return checkoutSha.toLowerCase();
};
export const resolveCheckoutCandidateSha = (repoRoot: string) => {
const result = spawnSync("git", ["rev-parse", "--verify", "HEAD^{commit}"], {
cwd: repoRoot,
encoding: "utf8",
});
if (result.status !== 0) throw new Error("Could not resolve the checked-out HEAD for QA provenance");
const checkoutSha = result.stdout.trim();
return validateCandidateSha(checkoutSha, checkoutSha);
};
+133
View File
@@ -0,0 +1,133 @@
// @vitest-environment node
import { spawnSync } from "node:child_process";
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { afterEach, describe, expect, test } from "vitest";
import { assertReleaseV2QaProfileEnvironment, releaseV2QaProfiles, validateCandidateSha } from "./provenance.mts";
import type { ReleaseV2QaProfile } from "./provenance.mts";
const disposableDirectories: string[] = [];
const repoRoot = path.resolve(import.meta.dirname, "../..");
const profileEnvironmentNames = ["DEMO_MODE", "DEMO_READ_ONLY", "UNSAFE_ENABLE_MOCK_INTEGRATION"] as const;
afterEach(async () => {
await Promise.all(
disposableDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })),
);
});
const environmentForProfile = (profile: ReleaseV2QaProfile): NodeJS.ProcessEnv => {
const environment: NodeJS.ProcessEnv = {
DEMO_MODE: "true",
DEMO_READ_ONLY: "false",
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
};
if (profile === "main-readonly") environment.DEMO_READ_ONLY = "true";
if (profile === "onboarding-fresh") environment.DEMO_MODE = "false";
return environment;
};
describe("release-v2 QA candidate provenance", () => {
test("accepts only the exact checked-out commit", () => {
const checkoutSha = "a".repeat(40);
expect(validateCandidateSha(checkoutSha.toUpperCase(), checkoutSha)).toBe(checkoutSha);
expect(() => validateCandidateSha("b".repeat(40), checkoutSha)).toThrow("checked-out HEAD");
expect(() => validateCandidateSha("not-a-sha", checkoutSha)).toThrow("full 40-character Git SHA");
});
});
describe("release-v2 QA profile provenance", () => {
test.each(releaseV2QaProfiles)("accepts the exact %s profile matrix", (profile) => {
expect(assertReleaseV2QaProfileEnvironment(profile, environmentForProfile(profile))).toEqual({
demoMode: profile !== "onboarding-fresh",
demoReadOnly: profile === "main-readonly",
unsafeMockIntegration: true,
});
});
test.each(profileEnvironmentNames)("rejects an unset %s flag", (environmentName) => {
const environment = environmentForProfile("main-writable");
delete environment[environmentName];
expect(() => assertReleaseV2QaProfileEnvironment("main-writable", environment)).toThrow(
new RegExp(`${environmentName} \\(expected (?:true|false)\\)`, "u"),
);
});
test.each(["1", "0", "TRUE", "False", "yes", " true ", "", "unexpected-private-marker"])(
"rejects the malformed strict boolean %j without echoing it",
(malformedValue) => {
for (const environmentName of profileEnvironmentNames) {
const environment = environmentForProfile("main-writable");
environment[environmentName] = malformedValue;
expect(() => assertReleaseV2QaProfileEnvironment("main-writable", environment)).toThrow(environmentName);
try {
assertReleaseV2QaProfileEnvironment("main-writable", environment);
} catch (error) {
expect((error as Error).message).not.toContain(malformedValue || "unexpected-private-marker");
}
}
},
);
test.each(releaseV2QaProfiles)("rejects every mismatched flag for %s", (profile) => {
const expectedEnvironment = environmentForProfile(profile);
for (const environmentName of profileEnvironmentNames) {
const environment = { ...expectedEnvironment };
environment[environmentName] = environment[environmentName] === "true" ? "false" : "true";
expect(() => assertReleaseV2QaProfileEnvironment(profile, environment)).toThrow(environmentName);
}
});
test("rejects a writable ambient environment before a read-only reset or manifest write", async () => {
const disposableDirectory = await mkdtemp(path.join(tmpdir(), "homarr-release-v2-qa-profile-flags-"));
disposableDirectories.push(disposableDirectory);
const databasePath = path.join(disposableDirectory, "fixture.sqlite");
const outputDirectory = path.join(disposableDirectory, "output");
const database = new Database(databasePath);
database.exec("CREATE TABLE mutation_guard (value TEXT NOT NULL); INSERT INTO mutation_guard VALUES ('preserved')");
database.close();
const databaseBefore = await readFile(databasePath);
const result = spawnSync(
process.execPath,
[
"--import",
"tsx",
path.join(repoRoot, "scripts/release-v2-qa/seed.mts"),
"--profile",
"main-readonly",
"--output",
outputDirectory,
"--reset",
],
{
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
DB_DRIVER: "better-sqlite3",
DB_URL: databasePath,
DEMO_MODE: "true",
DEMO_READ_ONLY: "false",
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
},
timeout: 30_000,
},
);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr).toContain("DEMO_READ_ONLY (expected true)");
expect(result.stderr).not.toContain("no such table");
expect(await readFile(databasePath)).toEqual(databaseBefore);
await expect(access(path.join(outputDirectory, "fixture-manifest.json"))).rejects.toMatchObject({ code: "ENOENT" });
});
});
+378
View File
@@ -0,0 +1,378 @@
import { randomUUID } from "node:crypto";
import { lstat, open, readFile, realpath, unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const runRootNamePattern = /^homarr-release-v2-qa(?:$|-[A-Za-z0-9][A-Za-z0-9._-]*)$/u;
const zoomOutputNamePattern = /^homarr-release-v2-qa-zoom-[A-Za-z0-9][A-Za-z0-9._-]*$/u;
const ipv6LoopbackHosts = new Set(["::1", "0:0:0:0:0:0:0:1"]);
const leaseSchemaVersion = 1;
const incompleteLeaseGraceMs = 30_000;
const missingPath = (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT";
const existingPath = (error: unknown) => (error as NodeJS.ErrnoException).code === "EEXIST";
export const assertSupportedRuntimePlatform = (platform: NodeJS.Platform = process.platform) => {
if (platform !== "linux") {
throw new Error(
"Release-v2 QA runtime mutations are supported only on Linux because safe process ownership and cleanup require procfs and POSIX process groups",
);
}
};
export const isPathWithin = (parent: string, child: string) => {
if (!path.isAbsolute(parent) || !path.isAbsolute(child)) return false;
const relativePath = path.relative(path.resolve(parent), path.resolve(child));
return relativePath.length === 0 || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
};
const canonicalTemporaryParent = async () => await realpath(path.resolve(tmpdir()));
const assertSafeTemporaryChild = async (
target: string,
options: {
allowMissing?: boolean;
label: string;
namePattern: RegExp;
nameRequirement: string;
},
) => {
const resolvedTarget = path.resolve(target);
const canonicalParent = await canonicalTemporaryParent();
const targetParent = path.dirname(resolvedTarget);
let realTargetParent: string;
try {
realTargetParent = await realpath(targetParent);
} catch (error) {
if (missingPath(error)) {
throw new Error(`${options.label} must be a direct child of the operating system temporary directory`, {
cause: error,
});
}
throw error;
}
if (realTargetParent !== canonicalParent) {
throw new Error(`${options.label} must be a direct child of the operating system temporary directory`);
}
const targetName = path.basename(resolvedTarget);
if (!options.namePattern.test(targetName)) {
throw new Error(options.nameRequirement);
}
const canonicalTarget = path.join(canonicalParent, targetName);
try {
const stats = await lstat(canonicalTarget);
if (stats.isSymbolicLink()) throw new Error(`${options.label} must not be a symlink`);
if (!stats.isDirectory()) throw new Error(`${options.label} must be a directory`);
if ((await realpath(canonicalTarget)) !== canonicalTarget) {
throw new Error(`${options.label} must not contain symlink components`);
}
} catch (error) {
if (missingPath(error) && options.allowMissing) return canonicalTarget;
throw error;
}
return canonicalTarget;
};
export const assertSafeContainedPath = async (parent: string, child: string, label: string) => {
const resolvedParent = path.resolve(parent);
const resolvedChild = path.resolve(child);
if (!isPathWithin(resolvedParent, resolvedChild)) {
throw new Error(`${label} escapes its approved parent`);
}
const realParent = await realpath(resolvedParent);
if (realParent !== resolvedParent) {
throw new Error(`${label} parent contains a symlink`);
}
const relativePath = path.relative(resolvedParent, resolvedChild);
let currentPath = resolvedParent;
for (const segment of relativePath.split(path.sep).filter(Boolean)) {
currentPath = path.join(currentPath, segment);
try {
const stats = await lstat(currentPath);
if (stats.isSymbolicLink()) throw new Error(`${label} contains a symlink`);
} catch (error) {
if (missingPath(error)) break;
throw error;
}
}
return resolvedChild;
};
export const assertSafeRunRoot = async (runRoot: string, options: { allowMissing?: boolean } = {}) =>
await assertSafeTemporaryChild(runRoot, {
allowMissing: options.allowMissing,
label: "The QA run-root",
namePattern: runRootNamePattern,
nameRequirement:
'The QA run-root basename must start with "homarr-release-v2-qa" and use only safe suffix characters',
});
export const assertSafeZoomOutput = async (outputDirectory: string, options: { allowMissing?: boolean } = {}) =>
await assertSafeTemporaryChild(outputDirectory, {
allowMissing: options.allowMissing,
label: "The zoom extension output",
namePattern: zoomOutputNamePattern,
nameRequirement:
'The zoom extension output basename must start with "homarr-release-v2-qa-zoom-" and use only safe suffix characters',
});
const readLinuxProcessStartMarker = async (pid: number) => {
if (process.platform !== "linux") return null;
try {
const processStat = await readFile(`/proc/${pid}/stat`, "utf8");
const commandEnd = processStat.lastIndexOf(")");
if (commandEnd < 0) return null;
const fieldsAfterCommand = processStat
.slice(commandEnd + 2)
.trim()
.split(/\s+/u);
const startTime = fieldsAfterCommand[19];
return startTime ? `linux:${startTime}` : null;
} catch {
return null;
}
};
const isProcessRunning = (pid: number) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
interface SlotLeaseOwner {
schemaVersion: 1;
pid: number;
processStartMarker: string | null;
token: string;
runRoot: string;
slot: number;
operation: string;
acquiredAt: string;
}
interface LeaseSnapshot {
device: bigint;
inode: bigint;
modifiedMs: number;
content: string;
owner: SlotLeaseOwner | null;
}
export interface SlotLease {
path: string;
owner: SlotLeaseOwner;
release: () => Promise<void>;
}
export const getSlotLeasePath = async (runRoot: string, slot: number) => {
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
const canonicalParent = await canonicalTemporaryParent();
return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.slot-${slot}.lease`);
};
export const getBuildLeasePath = async (runRoot: string) => {
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
const canonicalParent = await canonicalTemporaryParent();
return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.candidate-build.lease`);
};
const readLeaseSnapshot = async (leasePath: string): Promise<LeaseSnapshot> => {
const stats = await lstat(leasePath, { bigint: true });
if (stats.isSymbolicLink() || !stats.isFile()) {
throw new Error(`Refusing unsafe QA slot lease path: ${leasePath}`);
}
const content = await readFile(leasePath, "utf8");
let owner: SlotLeaseOwner | null = null;
try {
owner = JSON.parse(content) as SlotLeaseOwner;
} catch {
// An acquiring process may have created the file but not completed its marker write yet.
}
return {
device: stats.dev,
inode: stats.ino,
modifiedMs: Number(stats.mtimeMs),
content,
owner,
};
};
const isValidLeaseOwner = (owner: SlotLeaseOwner | null, runRoot: string, slot: number): owner is SlotLeaseOwner =>
owner?.schemaVersion === leaseSchemaVersion &&
Number.isInteger(owner.pid) &&
owner.pid > 0 &&
typeof owner.token === "string" &&
owner.token.length > 0 &&
path.resolve(owner.runRoot) === runRoot &&
owner.slot === slot;
const processMatchesLeaseOwner = async (owner: SlotLeaseOwner) => {
if (!isProcessRunning(owner.pid)) return false;
const observedStartMarker = await readLinuxProcessStartMarker(owner.pid);
if (owner.processStartMarker && observedStartMarker) {
return owner.processStartMarker === observedStartMarker;
}
// If the platform cannot verify process start identity, preserve the live lock rather than
// risking recovery over an active owner.
return true;
};
const sameLeaseSnapshot = (left: LeaseSnapshot, right: LeaseSnapshot) =>
left.device === right.device &&
left.inode === right.inode &&
left.modifiedMs === right.modifiedMs &&
left.content === right.content;
const recoverStaleLease = async (leasePath: string, snapshot: LeaseSnapshot) => {
const confirmation = await readLeaseSnapshot(leasePath);
if (!sameLeaseSnapshot(snapshot, confirmation)) {
throw new Error(`QA slot lease changed during stale recovery: ${leasePath}`);
}
await unlink(leasePath);
};
const acquireLease = async (
runRoot: string,
slot: number,
operation: string,
leasePath: string,
resourceLabel: string,
platform: NodeJS.Platform = process.platform,
): Promise<SlotLease> => {
assertSupportedRuntimePlatform(platform);
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
const owner: SlotLeaseOwner = {
schemaVersion: leaseSchemaVersion,
pid: process.pid,
processStartMarker: await readLinuxProcessStartMarker(process.pid),
token: randomUUID(),
runRoot: canonicalRunRoot,
slot,
operation,
acquiredAt: new Date().toISOString(),
};
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const handle = await open(leasePath, "wx", 0o600);
try {
await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
await handle.sync();
} finally {
await handle.close();
}
return {
path: leasePath,
owner,
release: async () => {
const snapshot = await readLeaseSnapshot(leasePath);
if (!isValidLeaseOwner(snapshot.owner, canonicalRunRoot, slot) || snapshot.owner?.token !== owner.token) {
throw new Error(`Refusing to release a QA ${resourceLabel} lease owned by another process: ${leasePath}`);
}
await recoverStaleLease(leasePath, snapshot);
},
};
} catch (error) {
if (!existingPath(error)) throw error;
const snapshot = await readLeaseSnapshot(leasePath);
const existingOwner = snapshot.owner;
if (!isValidLeaseOwner(existingOwner, canonicalRunRoot, slot)) {
if (Date.now() - snapshot.modifiedMs < incompleteLeaseGraceMs) {
throw new Error(`QA ${resourceLabel} lease is being initialized by another operation`, { cause: error });
}
await recoverStaleLease(leasePath, snapshot);
continue;
}
if (await processMatchesLeaseOwner(existingOwner)) {
throw new Error(
`QA ${resourceLabel} is busy with ${existingOwner.operation} owned by PID ${existingOwner.pid}`,
{
cause: error,
},
);
}
await recoverStaleLease(leasePath, snapshot);
}
}
throw new Error(`Unable to acquire QA ${resourceLabel} lease after stale recovery`);
};
export const acquireSlotLease = async (
runRoot: string,
slot: number,
operation: string,
platform: NodeJS.Platform = process.platform,
) => {
if (![1, 2, 3].includes(slot)) throw new Error("QA slot lease requires slot 1, 2, or 3");
const leasePath = await getSlotLeasePath(runRoot, slot);
return await acquireLease(runRoot, slot, operation, leasePath, `slot ${slot}`, platform);
};
export const acquireBuildLease = async (
runRoot: string,
operation: string,
platform: NodeJS.Platform = process.platform,
) => {
const leasePath = await getBuildLeasePath(runRoot);
return await acquireLease(runRoot, 0, operation, leasePath, "candidate build", platform);
};
const normalizedHost = (host: string) => {
const lowerHost = host.toLowerCase();
if (lowerHost.startsWith("[") && lowerHost.endsWith("]")) return lowerHost.slice(1, -1);
return lowerHost;
};
export const validateLoopbackHost = (host: string) => {
const normalized = normalizedHost(host);
if (normalized !== "127.0.0.1" && normalized !== "localhost" && !ipv6LoopbackHosts.has(normalized)) {
throw new Error("The fixture host must be an explicit loopback address");
}
return normalized;
};
export const validateFixtureUrl = (value: string) => {
let fixtureUrl: URL;
try {
fixtureUrl = new URL(value);
} catch {
throw new Error("The fixture URL must be a valid loopback HTTP origin");
}
if (
fixtureUrl.protocol !== "http:" ||
fixtureUrl.username ||
fixtureUrl.password ||
fixtureUrl.pathname !== "/" ||
fixtureUrl.search ||
fixtureUrl.hash
) {
throw new Error("The fixture URL must be a credential-free loopback HTTP origin");
}
try {
validateLoopbackHost(fixtureUrl.hostname);
} catch {
throw new Error("The fixture URL must be a credential-free loopback HTTP origin");
}
return fixtureUrl.origin;
};
export const sanitizeAppEnvironment = (environment: NodeJS.ProcessEnv) => {
const sanitized = { ...environment };
delete sanitized.QA_PASSWORD;
delete sanitized.QA_PASSWORD_FILE;
return sanitized;
};
export const createFixtureEnvironment = (runId: string): NodeJS.ProcessEnv => ({ QA_RUN_ID: runId });
+245
View File
@@ -0,0 +1,245 @@
// @vitest-environment node
import { mkdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import {
acquireBuildLease,
acquireSlotLease,
assertSafeContainedPath,
assertSafeRunRoot,
assertSupportedRuntimePlatform,
createFixtureEnvironment,
getSlotLeasePath,
isPathWithin,
sanitizeAppEnvironment,
validateFixtureUrl,
validateLoopbackHost,
} from "./safety.mts";
const temporaryPaths: string[] = [];
const uniqueName = (suffix: string) => `homarr-release-v2-qa-test-${process.pid}-${Date.now()}-${suffix}`;
afterEach(async () => {
await Promise.all(temporaryPaths.splice(0).map((target) => rm(target, { force: true, recursive: true })));
});
describe("release-v2 QA harness safety", () => {
test("supports runtime mutations only on Linux", () => {
expect(() => assertSupportedRuntimePlatform("linux")).not.toThrow();
expect(() => assertSupportedRuntimePlatform("darwin")).toThrow(/Linux/u);
expect(() => assertSupportedRuntimePlatform("win32")).toThrow(/Linux/u);
});
test("rejects an unsupported platform before creating a slot lease", async () => {
const runRoot = path.join(tmpdir(), uniqueName("unsupported-platform"));
const leasePath = await getSlotLeasePath(runRoot, 1);
temporaryPaths.push(leasePath);
await expect(acquireSlotLease(runRoot, 1, "unsupported-start", "darwin")).rejects.toThrow(/Linux/u);
await expect(stat(leasePath)).rejects.toMatchObject({ code: "ENOENT" });
});
test("accepts only direct, predictably named children of the temporary directory", async () => {
const safeRoot = path.join(tmpdir(), uniqueName("safe"));
temporaryPaths.push(safeRoot);
await expect(assertSafeRunRoot(safeRoot, { allowMissing: true })).resolves.toBe(path.resolve(safeRoot));
await expect(
assertSafeRunRoot(path.join(tmpdir(), "unrelated-homarr-release-v2-qa"), { allowMissing: true }),
).rejects.toThrow("must start with");
await expect(
assertSafeRunRoot(path.join(tmpdir(), "homarr-release-v2-qaevil"), { allowMissing: true }),
).rejects.toThrow("must start with");
await expect(
assertSafeRunRoot(path.join(tmpdir(), "nested", uniqueName("nested")), { allowMissing: true }),
).rejects.toThrow("direct child");
await expect(
assertSafeRunRoot(path.join(tmpdir(), "..", "var", "tmp", uniqueName("traversal")), { allowMissing: true }),
).rejects.toThrow("direct child");
});
test("canonicalizes a temporary-directory alias without widening containment", async () => {
const temporaryAlias = path.join(tmpdir(), uniqueName("temporary-alias"));
const runRootName = uniqueName("canonical-root");
temporaryPaths.push(temporaryAlias);
await symlink(tmpdir(), temporaryAlias, "dir");
await expect(assertSafeRunRoot(path.join(temporaryAlias, runRootName), { allowMissing: true })).resolves.toBe(
path.join(await realpath(tmpdir()), runRootName),
);
});
test("rejects a symlink run-root", async () => {
const target = path.join(tmpdir(), uniqueName("target"));
const linkedRoot = path.join(tmpdir(), uniqueName("linked"));
temporaryPaths.push(linkedRoot, target);
await mkdir(target);
await symlink(target, linkedRoot);
await expect(assertSafeRunRoot(linkedRoot)).rejects.toThrow("symlink");
});
test("rejects symlink components in a contained reset target", async () => {
const runRoot = path.join(tmpdir(), uniqueName("component-root"));
const externalTarget = path.join(tmpdir(), uniqueName("component-target"));
temporaryPaths.push(runRoot, externalTarget);
await mkdir(runRoot);
await mkdir(externalTarget);
await symlink(externalTarget, path.join(runRoot, "slots"));
await expect(
assertSafeContainedPath(runRoot, path.join(runRoot, "slots", "1"), "QA slot reset target"),
).rejects.toThrow("symlink");
});
test("catches a symlink swapped in after an earlier missing-target check", async () => {
const runRoot = path.join(tmpdir(), uniqueName("swap-root"));
const externalTarget = path.join(tmpdir(), uniqueName("swap-target"));
const slotPath = path.join(runRoot, "slots", "1");
temporaryPaths.push(runRoot, externalTarget);
await mkdir(path.dirname(slotPath), { recursive: true });
await mkdir(externalTarget);
await expect(assertSafeContainedPath(runRoot, slotPath, "QA slot mutation target")).resolves.toBe(slotPath);
await symlink(externalTarget, slotPath);
await expect(assertSafeContainedPath(runRoot, slotPath, "QA slot mutation target")).rejects.toThrow("symlink");
});
test("rejects a concurrent operation while a live slot lease is held", async () => {
const runRoot = path.join(tmpdir(), uniqueName("live-lease"));
const lease = await acquireSlotLease(runRoot, 1, "first-start");
temporaryPaths.push(lease.path);
await expect(acquireSlotLease(runRoot, 1, "second-start")).rejects.toThrow(/slot 1 is busy.*first-start/u);
await lease.release();
});
test("serializes the shared candidate build independently of slot leases", async () => {
const runRoot = path.join(tmpdir(), uniqueName("build-lease"));
const buildLease = await acquireBuildLease(runRoot, "first-build");
const slotLease = await acquireSlotLease(runRoot, 1, "slot-start");
temporaryPaths.push(buildLease.path, slotLease.path);
await expect(acquireBuildLease(runRoot, "second-build")).rejects.toThrow(/candidate build is busy.*first-build/u);
await buildLease.release();
await slotLease.release();
});
test("recovers a stale lease with a dead owner PID", async () => {
const runRoot = path.join(tmpdir(), uniqueName("stale-lease"));
const leasePath = await getSlotLeasePath(runRoot, 2);
temporaryPaths.push(leasePath);
await writeFile(
leasePath,
`${JSON.stringify({
schemaVersion: 1,
pid: 2_147_483_647,
processStartMarker: "linux:stale",
token: "stale-token",
runRoot: path.resolve(runRoot),
slot: 2,
operation: "stale-start",
acquiredAt: "2026-01-01T00:00:00.000Z",
})}\n`,
{ mode: 0o600 },
);
const lease = await acquireSlotLease(runRoot, 2, "replacement-start");
expect(lease.owner.operation).toBe("replacement-start");
await lease.release();
});
test("recovers a PID-reuse lease marker without signaling the unrelated process", async () => {
const runRoot = path.join(tmpdir(), uniqueName("pid-reuse-lease"));
const leasePath = await getSlotLeasePath(runRoot, 3);
temporaryPaths.push(leasePath);
await writeFile(
leasePath,
`${JSON.stringify({
schemaVersion: 1,
pid: process.pid,
processStartMarker: "linux:not-this-process",
token: "reused-pid-token",
runRoot: path.resolve(runRoot),
slot: 3,
operation: "abandoned-start",
acquiredAt: "2026-01-01T00:00:00.000Z",
})}\n`,
{ mode: 0o600 },
);
const lease = await acquireSlotLease(runRoot, 3, "safe-recovery");
expect(process.pid).toBeGreaterThan(0);
await lease.release();
});
test("uses path segments rather than string prefixes for containment", () => {
const parent = path.join(tmpdir(), "qa-artifacts", "packet-1");
expect(isPathWithin(parent, path.join(parent, "screen.png"))).toBe(true);
expect(isPathWithin(parent, `${parent}-spoof/screen.png`)).toBe(false);
expect(isPathWithin(parent, path.join(parent, "..", "packet-2", "screen.png"))).toBe(false);
expect(isPathWithin(parent, ".screenshots/release-v2/packet-1/screen.png")).toBe(false);
});
test.each(["http://127.0.0.1:3210", "http://localhost:3210", "http://[::1]:3210", "http://[0:0:0:0:0:0:0:1]:3210"])(
"accepts a credential-free loopback fixture origin: %s",
(fixtureUrl) => {
expect(validateFixtureUrl(fixtureUrl)).toBe(new URL(fixtureUrl).origin);
},
);
test.each([
"https://127.0.0.1:3210",
"http://user:secret@127.0.0.1:3210",
"http://0.0.0.0:3210",
"http://192.0.2.1:3210",
"http://localhost.example:3210",
"http://127.0.0.1:3210/path",
])("rejects an unsafe fixture URL without echoing it: %s", (fixtureUrl) => {
expect(() => validateFixtureUrl(fixtureUrl)).toThrow("fixture URL");
});
test.each(["127.0.0.1", "localhost", "::1", "[::1]", "0:0:0:0:0:0:0:1"])(
"accepts the loopback fixture bind host %s",
(host) => {
expect(validateLoopbackHost(host)).toBeTruthy();
},
);
test.each(["0.0.0.0", "::", "192.0.2.1", "example.test"])("rejects the non-loopback bind host %s", (host) => {
expect(() => validateLoopbackHost(host)).toThrow("loopback");
});
test("removes both QA password inputs from the spawned app environment", () => {
const environment = sanitizeAppEnvironment({
QA_PASSWORD: "do-not-forward",
QA_PASSWORD_FILE: "/tmp/do-not-forward",
QA_RUN_ID: "qa-run",
});
expect(environment).toEqual({ QA_RUN_ID: "qa-run" });
expect("QA_PASSWORD" in environment).toBe(false);
expect("QA_PASSWORD_FILE" in environment).toBe(false);
});
test("uses an explicit fixture environment allowlist", () => {
const environment = createFixtureEnvironment("qa-run");
expect(environment).toEqual({ QA_RUN_ID: "qa-run" });
for (const forbiddenName of [
"QA_PASSWORD",
"QA_PASSWORD_FILE",
"DB_URL",
"AUTH_SECRET",
"API_KEY",
"SECRET_ENCRYPTION_KEY",
"HOME",
"PATH",
]) {
expect(forbiddenName in environment).toBe(false);
}
});
});
File diff suppressed because it is too large. Load diff