fix(ci): harden release and e2e verification

This commit is contained in:
Marc-André Bombeck committed 2026-07-21 02:30:40 +02:00
1 parent b38f41b99d
commit 5fe952b63d
10 files changed
+87 -24

No files matched your search

+2 -1
View File
@@ -37,10 +37,11 @@ jobs:
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
ENCRYPTION_ACTIVE_KEY_ID: v1
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
AUTH_RP_NAME: HealthLog
AUTH_RP_ID: localhost
AUTH_RP_ORIGIN: http://localhost:3000
SESSION_COOKIE_SECURE: "false"
NODE_ENV: production
steps:
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
# string "0" — failing crypto's hex/base64 length check and breaking
# the backups + integration-status suites for 50+ runs (regression
# since v1.4.13). Quoting forces a literal scalar.
API_TOKEN_HMAC_KEY: "integration-tests-do-not-use-in-prod"
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
+4 -3
View File
@@ -111,7 +111,7 @@ jobs:
-e DATABASE_URL="$DATABASE_URL" \
-e ENCRYPTION_KEYS='{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
-e ENCRYPTION_ACTIVE_KEY_ID=v1 \
-e API_TOKEN_HMAC_KEY=verify-hmac-key-32-bytes-padding-x \
-e API_TOKEN_HMAC_KEY=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc \
-e AUTH_RP_NAME=HealthLog \
-e AUTH_RP_ID=localhost \
-e AUTH_RP_ORIGIN=http://localhost:3000 \
@@ -135,10 +135,11 @@ jobs:
exit 1
fi
EXPECTED_VERSION="${IMAGE_TAG#v}"
echo "$body" | jq .
if ! jq -e --arg expected "$IMAGE_TAG" '.data.version == $expected' <<< "$body" >/dev/null; then
if ! jq -e --arg expected "$EXPECTED_VERSION" '.data.version == $expected' <<< "$body" >/dev/null; then
actual="$(jq -r '.data.version // "<missing>"' <<< "$body")"
echo "::error::Version mismatch: exact image reports '$actual', expected '$IMAGE_TAG'." >&2
echo "::error::Version mismatch: exact image reports '$actual', expected '$EXPECTED_VERSION' (release tag '$IMAGE_TAG')." >&2
exit 1
fi
if ! jq -e --arg expected "$BUILD_SHA" '.data.buildSha == $expected' <<< "$body" >/dev/null; then
+4 -4
View File
@@ -80,10 +80,10 @@ RUN addgroup --system --gid 1001 nodejs && \
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next's standalone output must carry every externalized worker dependency.
# Fail the image build here if output tracing ever stops shipping one rather
# than compensating with a second, independently versioned npm installation.
RUN node -e "require.resolve('pg-boss'); require.resolve('@prisma/adapter-pg'); require.resolve('pg'); require.resolve('@prisma/client')"
# Next's standalone output must carry the externalized worker dependencies.
# Prisma's pure-JS adapter stays bundled; only modules loaded through native
# Node resolution belong in this runtime assertion.
RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the
# native binary's package into the standalone tree but NOT the pnpm symlinks that
+18
View File
@@ -94,9 +94,27 @@ test.describe("Settings → Export & Import", () => {
}) => {
// v1.18.0 (S5) — the panel (and its disclosure) live on the
// dedicated Gesundheitsakte section now.
await page.route("**/api/share-links", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [], error: null, meta: null }),
});
});
const sharingDataReady = page.waitForResponse(
(response) =>
response.status() === 200 &&
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await sharingDataReady;
const toggle = page.getByTestId("health-record-included-data-toggle");
await expect(toggle).toBeVisible({ timeout: 10_000 });
// Collapsed on first render — the checklist panel is absent.
+21 -4
View File
@@ -57,7 +57,14 @@ interface CreatedShare {
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
const sharingDataReady = page.waitForResponse(
(response) =>
response.status() === 200 &&
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/sharing");
await sharingDataReady;
await page.locator("#share-label").fill(label);
// Open the document picker and isolate the seeded trio.
@@ -69,13 +76,18 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
// Exactly the three share-fixture documents match the prefix.
const rows = list.getByRole("button");
await expect(rows).toHaveCount(3);
for (let i = 0; i < 3; i++) await rows.nth(i).click();
for (let i = 0; i < 3; i++) {
const row = rows.nth(i);
await row.click();
await expect(row).toHaveAttribute("aria-pressed", "true");
}
await page.getByRole("button", { name: "Done" }).click();
// Three chips staged before create.
await expect(
page.getByTestId("share-attached-chips").getByRole("listitem"),
).toHaveCount(3);
await expect(page.getByRole("button", { name: "Create link" })).toBeEnabled();
await page.getByRole("button", { name: "Create link" }).click();
@@ -150,9 +162,14 @@ test.describe("clinician document sharing", () => {
clinician.getByRole("heading", { name: "Documents" }),
).toBeVisible();
// Class A image → inline <img> at the token-scoped serve route.
await expect(
clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`),
).toBeVisible();
const inlineImage = clinician.getByRole("img", {
name: "Share e2e scan",
});
await expect(inlineImage).toBeVisible();
await expect(inlineImage).toHaveAttribute(
"src",
new RegExp(`/d/${SHARE_JPEG_DOC_ID}$`),
);
// Class A PDF → inline <iframe> at the same route family.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_PDF_DOC_ID}"]`),
-1
View File
@@ -231,7 +231,6 @@ const nextConfig: NextConfig = {
serverExternalPackages: [
"@prisma/client",
"pg-boss",
"@prisma/adapter-pg",
"pg",
// Document PDF handling MUST run the real, un-bundled modules. When
// Turbopack bundles pdfjs-dist into the server chunks, its runtime
+18 -2
View File
@@ -21,8 +21,13 @@ type WorkflowStep = {
with?: Record<string, string | number>;
};
type WorkflowJob = {
env?: Record<string, string>;
steps?: WorkflowStep[];
};
type Workflow = {
jobs: Record<string, { steps?: WorkflowStep[] }>;
jobs: Record<string, WorkflowJob>;
};
afterEach(() => {
@@ -58,6 +63,16 @@ describe("Playwright CI determinism", () => {
"if-no-files-found": "ignore",
});
});
it("uses production-valid secrets and plain-HTTP cookies", () => {
const workflow = parse(
readRepoFile(".github/workflows/e2e.yml"),
) as Workflow;
const env = workflow.jobs.e2e?.env;
expect(env?.API_TOKEN_HMAC_KEY).toMatch(/^[0-9a-f]{64}$/);
expect(env?.SESSION_COOKIE_SECURE).toBe("false");
});
});
describe("integration environment isolation", () => {
@@ -73,7 +88,8 @@ describe("integration environment isolation", () => {
"0000000000000000000000000000000000000000000000000000000000000000",
ENCRYPTION_KEYS: "",
ENCRYPTION_ACTIVE_KEY_ID: "",
API_TOKEN_HMAC_KEY: "integration-test-hmac-key-32-bytes-minimum",
API_TOKEN_HMAC_KEY:
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
SESSION_SECRET: "integration-test-session-secret-32-bytes",
});
expect(
+17 -7
View File
@@ -244,12 +244,18 @@ describe("release container inputs", () => {
).toHaveLength(3);
});
it("relies on and verifies standalone dependency resolution", () => {
it("bundles Prisma's adapter and verifies only runtime externals", () => {
const dockerfile = readRepoFile("Dockerfile");
const nextConfig = readRepoFile("next.config.ts");
expect(dockerfile).not.toContain("/opt/pg-boss");
expect(dockerfile).not.toContain('NODE_PATH="/opt/pg-boss/node_modules"');
expect(dockerfile).toMatch(
/require\.resolve\(['"]pg-boss['"]\)[\s\S]*require\.resolve\(['"]@prisma\/adapter-pg['"]\)[\s\S]*require\.resolve\(['"]pg['"]\)/,
/require\.resolve\(['"]pg-boss['"]\)[\s\S]*require\.resolve\(['"]pg['"]\)/,
);
expect(dockerfile).not.toContain("require.resolve('@prisma/adapter-pg')");
expect(dockerfile).not.toContain("require.resolve('@prisma/client')");
expect(nextConfig).not.toMatch(
/serverExternalPackages:\s*\[[\s\S]*["']@prisma\/adapter-pg["']/,
);
});
});
@@ -374,12 +380,16 @@ describe("exact post-publish verification", () => {
expect(stepScript(workflow, "verify", "Run migrations")).toContain(
"${IMAGE_REF}@${IMAGE_DIGEST}",
);
expect(stepScript(workflow, "verify", "Start exact image")).toContain(
"${IMAGE_REF}@${IMAGE_DIGEST}",
);
expect(stepScript(workflow, "verify", "Verify exact version")).toContain(
'jq -e --arg expected "$IMAGE_TAG"',
const startScript = stepScript(workflow, "verify", "Start exact image");
expect(startScript).toContain("${IMAGE_REF}@${IMAGE_DIGEST}");
expect(startScript).toMatch(/API_TOKEN_HMAC_KEY=[0-9a-f]{64}(?:\s|\\)/);
const versionScript = stepScript(
workflow,
"verify",
"Verify exact version",
);
expect(versionScript).toContain('EXPECTED_VERSION="${IMAGE_TAG#v}"');
expect(versionScript).toContain('jq -e --arg expected "$EXPECTED_VERSION"');
expect(stepScript(workflow, "verify", "Verify health")).toContain(
"jq -e '.status == \"ok\"'",
);
+2 -1
View File
@@ -21,7 +21,8 @@ export default defineConfig({
"0000000000000000000000000000000000000000000000000000000000000000",
ENCRYPTION_KEYS: "",
ENCRYPTION_ACTIVE_KEY_ID: "",
API_TOKEN_HMAC_KEY: "integration-test-hmac-key-32-bytes-minimum",
API_TOKEN_HMAC_KEY:
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
SESSION_SECRET: "integration-test-session-secret-32-bytes",
},
include: ["tests/integration/**/*.test.ts"],