import assert from "node:assert/strict"; import test from "node:test"; import { classifyVersion2Execution, deployExactImage, expandTokens, mergeEnvironment, resolveBuildSecretArguments, resolveSecretEnvironment, orderApplications, resolveDeploymentBranch, resolvePullRequestHeadBranch, selectDeployment, selectPipeline, toSavePayload, validateArtifact, validateApplication, validatePromotionPullRequestSource, validateTarget, validateVersion2Pipeline, verifyEndpoint, } from "./deploy.mjs"; function response(status, body, headers = {}) { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } }); } function app(overrides = {}) { return { id: "app-one", name: "One", appType: "APP", projectId: "project-one", sourceType: "GIT", buildMethod: "DOCKERFILE", containerImageSource: null, containerRegistryUsername: null, containerRegistryPassword: null, dockerfilePath: "./Dockerfile", replicas: 1, envVars: "SECRET=preserved\nENVIRONMENT=old", ingressNetworkPolicy: "[]", egressNetworkPolicy: "[]", useNetworkPolicy: false, healthCheckPeriodSeconds: 10, healthCheckTimeoutSeconds: 2, healthCheckFailureThreshold: 3, appDomains: [], appPorts: [], appNodePorts: [], appFileMounts: [], appVolumes: [], appBasicAuths: [], project: {}, createdAt: "ignored", updatedAt: "ignored", ...overrides, }; } test("selects only the deployment matching the triggering branch", () => { const config = { version: 1, deployments: [{ branch: "main" }, { branch: "v1" }] }; assert.equal(selectDeployment(config, "v1")?.branch, "v1"); assert.equal(selectDeployment(config, "other"), null); }); test("resolves push and pull request deployment branches without accepting empty context values", () => { assert.equal( resolveDeploymentBranch({ eventName: "push", environment: { GITHUB_REF_NAME: "", GITEA_REF_NAME: "main" }, }), "main", ); assert.equal( resolveDeploymentBranch({ eventName: "pull_request", environment: { GITHUB_BASE_REF: "", GITEA_BASE_REF: "" }, eventPayload: { pull_request: { base: { ref: "main" } } }, }), "main", ); assert.throws( () => resolveDeploymentBranch({ eventName: "pull_request", environment: {}, eventPayload: {} }), /Pull request base branch/, ); }); test("resolves the pull request head branch from direct and payload context", () => { assert.equal( resolvePullRequestHeadBranch({ environment: { GITHUB_HEAD_REF: "staging" } }), "staging", ); assert.equal( resolvePullRequestHeadBranch({ environment: {}, eventPayload: { pull_request: { head: { ref: "release-candidate" } } }, }), "release-candidate", ); assert.throws( () => resolvePullRequestHeadBranch({ environment: {}, eventPayload: {} }), /Pull request head branch/, ); }); test("production pull requests validate tested candidates without rebuilding", () => { assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "pull_request"), "build-validation"); assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "push"), "build-deploy"); assert.equal(classifyVersion2Execution({ strategy: "promote" }, "pull_request"), "validate-candidate"); assert.equal(classifyVersion2Execution({ strategy: "promote" }, "push"), "promote"); assert.equal( validatePromotionPullRequestSource({ source: { branch: "staging" } }, "staging"), "staging", ); assert.throws( () => validatePromotionPullRequestSource({ source: { branch: "staging" } }, "feature"), /must originate from staging, not feature/, ); }); test("validates OCI paths and repository-local build paths", () => { assert.equal(validateTarget({ name: "Web", image: "Owner/Web", appId: "app-1" }).image, "owner/web"); assert.throws(() => validateTarget({ name: "Web", image: "owner/web", appId: "app-1", context: "../secret" }), /inside/); assert.throws( () => validateTarget({ name: "Web", image: "owner/web", appId: "app-1", deploymentAttempts: 0 }), /positive integer/, ); }); test("version 2 reuses one artifact across ordered applications", () => { const config = { version: 2, artifacts: [ { name: "webapp", image: "InnoMieter/Webapp", requiredFiles: ["/app/server.js"] }, { name: "thumbnail", image: "innomieter/thumbnail", dockerfile: "Dockerfile.thumbnail" }, ], pipelines: [ { branch: "staging", strategy: "candidate", applications: [ { name: "worker", artifact: "webapp", appId: "app-worker", dependsOn: ["web"] }, { name: "web", artifact: "webapp", appId: "app-web" }, ], }, ], }; assert.equal(selectPipeline(config, "staging")?.strategy, "candidate"); const pipeline = validateVersion2Pipeline(config, "staging"); assert.deepEqual(pipeline.artifacts.map((artifact) => artifact.name), ["webapp", "thumbnail"]); assert.deepEqual(pipeline.applications.map((application) => application.name), ["web", "worker"]); assert.equal(pipeline.applications[1].artifact, "webapp"); }); test("version 2 validates promotion contracts and token expansion", () => { const sha = "1234567890abcdef1234567890abcdef12345678"; assert.equal(expandTokens("build-$sha12-$version", { sha, version: "v1.2.3" }), "build-1234567890ab-v1.2.3"); assert.throws( () => validateVersion2Pipeline({ version: 2, artifacts: [{ name: "web", image: "owner/web" }], pipelines: [{ branch: "prod", strategy: "promote", applications: [{ name: "web", artifact: "web", appId: "app" }] }], }, "prod"), /requires release configuration/, ); assert.throws(() => validateArtifact({ name: "web", image: "owner/web", requiredFiles: ["../secret"] }), /safe absolute path/); assert.throws( () => validateApplication({ name: "web", artifact: "missing", appId: "app" }, new Set(["known"])), /unknown artifact/, ); }); test("passes declared Actions secrets to Docker only through BuildKit secret mounts", () => { const secretValue = "must-not-appear-in-docker-arguments"; const artifact = validateArtifact({ name: "web", image: "owner/web", buildSecrets: { "next-server-actions-encryption-key": "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY", }, }); const args = resolveBuildSecretArguments(artifact.buildSecrets, { NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: secretValue, }); assert.deepEqual(args, [ "--secret", "id=next-server-actions-encryption-key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY", ]); assert.doesNotMatch(JSON.stringify(args), new RegExp(secretValue)); assert.throws( () => resolveBuildSecretArguments(artifact.buildSecrets, {}), /Missing Actions secret NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/, ); assert.throws( () => validateArtifact({ name: "web", image: "owner/web", buildSecrets: { "../invalid": "SECRET" } }), /Invalid BuildKit secret ID/, ); }); test("application dependencies reject missing nodes and cycles", () => { assert.throws( () => orderApplications([{ name: "web", dependsOn: ["missing"] }]), /unknown application/, ); assert.throws( () => orderApplications([ { name: "web", dependsOn: ["worker"] }, { name: "worker", dependsOn: ["web"] }, ]), /cycle/, ); }); test("preserves response-only fields and existing environment secrets safely", () => { const payload = toSavePayload(app()); assert.equal(payload.createdAt, undefined); assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new"); }); test("maps only explicitly declared Actions secrets into runtime environment", () => { assert.deepEqual( resolveSecretEnvironment( { MINIO_ENDPOINT: "MINIO_ENDPOINT", MINIO_REGION: "MINIO_REGION" }, { MINIO_ENDPOINT: "https://minio.example.test", MINIO_REGION: "us-east-1" }, ), { MINIO_ENDPOINT: "https://minio.example.test", MINIO_REGION: "us-east-1" }, ); assert.throws( () => resolveSecretEnvironment({ MINIO_ENDPOINT: "MINIO_ENDPOINT" }, {}), /Missing Actions secret MINIO_ENDPOINT/, ); assert.throws( () => resolveSecretEnvironment({ "INVALID-KEY": "MINIO_ENDPOINT" }, { MINIO_ENDPOINT: "value" }), /Invalid secret environment key/, ); }); test("postflight waits for the exact expected identity", async () => { let attempt = 0; const result = await verifyEndpoint( { url: "https://app.example", expectedHeader: "x-build", expectedValue: "$sha12", attempts: 2, intervalSeconds: 0 }, { sha: "1234567890abcdef1234567890abcdef12345678", sleep: async () => {}, fetchImpl: async () => { attempt += 1; return response(200, {}, { "x-build": attempt === 2 ? "1234567890ab" : "old" }); }, }, ); assert.equal(result.headerValue, "1234567890ab"); }); test("failed public verification restores and redeploys the previous source", async () => { const original = app(); let current = original; let deployments = 0; const client = { getApp: async () => current, saveApp: async (payload) => { current = { ...current, ...payload }; }, deployApp: async () => ({ deploymentId: `deployment-${++deployments}` }), getDeployment: async (_appId, deploymentId) => ({ deploymentId, status: "DEPLOYED" }), }; await assert.rejects( deployExactImage({ client, appId: "app-one", image: `gitea.example/owner/app@sha256:${"a".repeat(64)}`, registryUsername: "deploy", registryToken: "token", sha: "1".repeat(40), sleep: async () => {}, postflight: { url: "https://app.example", attempts: 1 }, fetchImpl: async () => response(503, {}), }), /rolled back/, ); assert.equal(deployments, 2); assert.equal(current.sourceType, original.sourceType); assert.equal(current.containerImageSource, original.containerImageSource); }); test("transient QuickStack deployment failures retry the desired immutable image", async () => { let current = app(); let deployments = 0; const image = `gitea.example/owner/app@sha256:${"b".repeat(64)}`; const client = { getApp: async () => current, saveApp: async (payload) => { current = { ...current, ...payload }; }, deployApp: async () => ({ deploymentId: `deployment-${++deployments}` }), getDeployment: async (_appId, deploymentId) => ({ deploymentId, status: deploymentId === "deployment-1" ? "ERROR" : "DEPLOYED", }), }; const result = await deployExactImage({ client, appId: "app-one", image, registryUsername: "deploy", registryToken: "token", sha: "2".repeat(40), deploymentAttempts: 2, deploymentRetrySeconds: 0, sleep: async () => {}, }); assert.equal(deployments, 2); assert.equal(result.image, image); assert.equal(current.sourceType, "CONTAINER"); assert.equal(current.containerImageSource, image); }); test("a newly created deployment may be briefly unavailable without triggering rollback", async () => { let current = app(); let deployments = 0; let lookups = 0; const image = `gitea.example/owner/app@sha256:${"c".repeat(64)}`; const client = { getApp: async () => current, saveApp: async (payload) => { current = { ...current, ...payload }; }, deployApp: async () => ({ deploymentId: `deployment-${++deployments}` }), getDeployment: async (_appId, deploymentId) => { lookups += 1; if (lookups === 1) throw Object.assign(new Error("not visible"), { statusCode: 404 }); return { deploymentId, status: "DEPLOYED" }; }, }; const result = await deployExactImage({ client, appId: "app-one", image, registryUsername: "deploy", registryToken: "token", sha: "3".repeat(40), sleep: async () => {}, }); assert.equal(deployments, 1); assert.equal(lookups, 2); assert.equal(result.image, image); assert.equal(current.sourceType, "CONTAINER"); });