test: add release v2 mega QA seeder
This commit is contained in:
1 parent
ac62e5bfd6
commit
2a4658d255
10 files changed
+2802
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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
Reference in new issue
Block a user