From 4799dd0251835d868b8123d004a6cb3ffc2c30cc Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 1 Sep 2026 07:12:47 +0200 Subject: [PATCH] test: isolate QA trusted certificates --- scripts/release-v2-qa/runtime.mts | 14 +++++++++++++ scripts/release-v2-qa/runtime.spec.ts | 30 +++++++++++++++++++++++++++ scripts/release-v2-qa/verify.mts | 10 ++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/scripts/release-v2-qa/runtime.mts b/scripts/release-v2-qa/runtime.mts index 2b9344cc6..cc395dd3f 100644 --- a/scripts/release-v2-qa/runtime.mts +++ b/scripts/release-v2-qa/runtime.mts @@ -123,6 +123,7 @@ interface RuntimeManifest { runRoot: string; slotDir: string; dbPath: string; + trustedCertificatePath: string; fixtureManifestPath: string; nextDistDir: string; url: string; @@ -140,6 +141,16 @@ interface RuntimeManifest { docker: { redisContainer: string }; } +export const createSlotTrustedCertificateDirectory = async (runRoot: string, slotDir: string) => { + await assertSafeContainedPath(runRoot, slotDir, "QA slot directory"); + const trustedCertificatePath = path.join(slotDir, "trusted-certificates"); + await assertSafeContainedPath(slotDir, trustedCertificatePath, "QA trusted certificate directory"); + await mkdir(trustedCertificatePath, { mode: 0o700 }); + await assertSafeContainedPath(slotDir, trustedCertificatePath, "QA trusted certificate directory"); + await chmod(trustedCertificatePath, 0o700); + return trustedCertificatePath; +}; + const usage = () => { console.log( "Usage: runtime.mts --slot <1|2|3> [--profile ] [--run-root ] [--candidate ] [--reset]", @@ -664,6 +675,7 @@ const startWithLease = async (options: Options) => { const runId = `release-v2-${candidateSha.slice(0, 8)}-s${options.slot}-${Date.now()}`; const redisContainer = `homarr-release-v2-qa-redis-s${options.slot}-${candidateSha.slice(0, 8)}-${Date.now().toString(36)}`; const dbPath = path.join(slotDir, "db.sqlite"); + const trustedCertificatePath = await createSlotTrustedCertificateDirectory(options.runRoot, slotDir); const fixtureReadyPath = path.join(slotDir, "fixture-ready.json"); const fixtureManifestPath = path.join(slotDir, "fixture-manifest.json"); const fixtureLogPath = path.join(slotDir, "fixture.log"); @@ -725,6 +737,7 @@ const startWithLease = async (options: Options) => { AUTH_COOKIE_PREFIX: `homarr_qa_s${options.slot}`, DB_DRIVER: "better-sqlite3", DB_URL: dbPath, + LOCAL_CERTIFICATE_PATH: trustedCertificatePath, DEMO_MODE: String(flags.demoMode), DEMO_READ_ONLY: String(flags.demoReadOnly), UNSAFE_ENABLE_MOCK_INTEGRATION: String(flags.unsafeMockIntegration), @@ -807,6 +820,7 @@ const startWithLease = async (options: Options) => { runRoot: options.runRoot, slotDir, dbPath, + trustedCertificatePath, fixtureManifestPath, nextDistDir: candidateBuild.buildDir, url, diff --git a/scripts/release-v2-qa/runtime.spec.ts b/scripts/release-v2-qa/runtime.spec.ts index a888dacd9..656e4ac20 100644 --- a/scripts/release-v2-qa/runtime.spec.ts +++ b/scripts/release-v2-qa/runtime.spec.ts @@ -1,7 +1,10 @@ // @vitest-environment node +import { mkdtemp, mkdir, rm, stat } from "node:fs/promises"; import { createServer } from "node:net"; import type { Server } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { @@ -9,12 +12,14 @@ import { createAppLaunchCommand, createCandidateBuildPaths, createRuntimeExecutionContract, + createSlotTrustedCertificateDirectory, launchWithPortRetry, reserveLoopbackPort, validateRuntimeExecutionContract, } from "./runtime.mts"; const openServers: Server[] = []; +const temporaryDirectories: string[] = []; const listen = async (server: Server, port: number) => await new Promise((resolve, reject) => { @@ -36,6 +41,31 @@ afterEach(async () => { if (server.listening) await close(server); }), ); + await Promise.all(temporaryDirectories.splice(0).map(async (directory) => await rm(directory, { recursive: true }))); +}); + +describe("release-v2 QA slot storage", () => { + test("creates a restrictive slot-local trusted certificate directory", async () => { + const runRoot = await mkdtemp(path.join(tmpdir(), "homarr-release-v2-qa-runtime-spec-")); + temporaryDirectories.push(runRoot); + const slotDir = path.join(runRoot, "slots", "1"); + await mkdir(slotDir, { recursive: true, mode: 0o700 }); + + const trustedCertificatePath = await createSlotTrustedCertificateDirectory(runRoot, slotDir); + const directoryStats = await stat(trustedCertificatePath); + + expect(trustedCertificatePath).toBe(path.join(slotDir, "trusted-certificates")); + expect(directoryStats.isDirectory()).toBe(true); + expect(directoryStats.mode & 0o777).toBe(0o700); + }); + + test("refuses a trusted certificate directory outside the approved run root", async () => { + const runRoot = await mkdtemp(path.join(tmpdir(), "homarr-release-v2-qa-runtime-spec-")); + const outsideRoot = await mkdtemp(path.join(tmpdir(), "homarr-release-v2-qa-runtime-outside-")); + temporaryDirectories.push(runRoot, outsideRoot); + + await expect(createSlotTrustedCertificateDirectory(runRoot, outsideRoot)).rejects.toThrow(/escapes/u); + }); }); describe("release-v2 QA runtime port startup", () => { diff --git a/scripts/release-v2-qa/verify.mts b/scripts/release-v2-qa/verify.mts index 1450e6ec9..2c18b890c 100644 --- a/scripts/release-v2-qa/verify.mts +++ b/scripts/release-v2-qa/verify.mts @@ -112,6 +112,7 @@ interface RuntimeManifest { runRoot: string; slotDir: string; dbPath: string; + trustedCertificatePath: string; fixtureManifestPath: string; nextDistDir: string; url: string; @@ -646,6 +647,7 @@ const validateRuntimeManifest = async ( const expectedSlotDir = join(runtime.runRoot, "slots", String(runtime.slot)); const expectedManifestPath = join(expectedSlotDir, "runtime-manifest.json"); const expectedDbPath = join(expectedSlotDir, "db.sqlite"); + const expectedTrustedCertificatePath = join(expectedSlotDir, "trusted-certificates"); const expectedFixtureManifestPath = join(expectedSlotDir, "fixture-manifest.json"); const expectedBuild = createCandidateBuildPaths(campaignCandidateSha); if (resolve(runtime.slotDir) !== resolve(expectedSlotDir)) @@ -653,6 +655,9 @@ const validateRuntimeManifest = async ( if (resolve(manifestPath) !== resolve(expectedManifestPath)) errors.push(`${label}: runtime manifest is outside the selected slot`); if (resolve(runtime.dbPath) !== resolve(expectedDbPath)) errors.push(`${label}: dbPath is outside the selected slot`); + if (resolve(runtime.trustedCertificatePath ?? "") !== resolve(expectedTrustedCertificatePath)) { + errors.push(`${label}: trusted certificate path is outside the selected slot`); + } if (resolve(runtime.fixtureManifestPath) !== resolve(expectedFixtureManifestPath)) { errors.push(`${label}: fixture manifest path is outside the selected slot`); } @@ -675,8 +680,11 @@ const validateRuntimeManifest = async ( errors.push(`${label}: runRoot must not be a symlink`); if ((await realpath(runtime.slotDir)) !== resolve(runtime.slotDir)) errors.push(`${label}: slotDir must not be a symlink`); + if ((await realpath(runtime.trustedCertificatePath)) !== resolve(runtime.trustedCertificatePath)) { + errors.push(`${label}: trusted certificate path must exist inside the selected slot and must not be a symlink`); + } } catch { - errors.push(`${label}: runRoot or slotDir is missing`); + errors.push(`${label}: runRoot, slotDir, or trusted certificate path is missing`); } let appUrl: URL | undefined;