diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 3682913..8cd80ae 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -17,42 +17,42 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md -56691e95912beab52b9e072ad0735a706934def093d91a83a235119e03f7ef21 111308 coverage/tmp/coverage-14112-1785342182302-0.json -35e206d8e448f56715fdeaafc82a00680df74edce1bd1d74d2c626c0ebdc8dd4 344934 coverage/tmp/coverage-18504-1785342182921-0.json -cd5a1817ee7f405f72ede736ec2ccc5831a14c4ab99c3ae9e1af11c93c7f4c13 354684 coverage/tmp/coverage-23740-1785342180573-0.json -f2dbe68eb60983b2038bfda8448e148e0d762794651c8f0ef0a5985549f35d11 301398 coverage/tmp/coverage-30356-1785342180023-0.json -04e0ac6a30fdcbbc9f9856697b228626e0355b7c98c6045cf0db01de0c93fc68 344613 coverage/tmp/coverage-35156-1785342187759-0.json -0d4e6a6dcc0ce9f8d2d9e304dbbeccc2b551fbe79637fdd16b7e860a58685d68 302513 coverage/tmp/coverage-35780-1785342181205-0.json -3c5a7491afc0247568bce0824331c16e2e9af0693b216e01fe73bb41ad4fe91f 327420 coverage/tmp/coverage-38404-1785342179951-0.json -24ca89f314bacd35321b8772706e5c4658602abf90e701561d23f67a1d4dd10e 288320 coverage/tmp/coverage-38780-1785342181696-0.json -8711b58d90963d5c5239011527a8d504fde3bed93671e09b8e6f8e865837d928 358165 coverage/tmp/coverage-39472-1785342183215-0.json -5e8d4cd9c0b348618088733e133986c8bff8d822b7a0f0fb6c04a777f025e249 303090 coverage/tmp/coverage-39536-1785342182261-0.json -9aa7a9e3b5daa4a3f2139df2a8b8857d851ef2ce8e3c7679db787f6e72f5df38 302346 coverage/tmp/coverage-41596-1785342181753-0.json -cd9419f7829a06e3932ca774fb06c51b3637639e8f750fe0bbce400921c20866 321057 coverage/tmp/coverage-42088-1785342182383-0.json -5b75886d617be03df5d1271223cbd8cc660d6603368349d7f7740e7b5005d3d2 304508 coverage/tmp/coverage-42724-1785342180308-0.json -e567e65494a3fadf0effdd7058761c890ff35411532673fb6d0feb0f7d0d681f 346823 coverage/tmp/coverage-44760-1785342180156-0.json -4f2a6563b9710b23c918d10f1a2d713975f0f5d2feedb5731e87a441bb9eabae 352006 coverage/tmp/coverage-44924-1785342180167-0.json -5e355c76dab29a1ecebe30d390970397ae6ccd71798b111fa67e395a1310ad5e 361403 coverage/tmp/coverage-46136-1785342180786-0.json -1623101020fed0786a83d5244a03df9bf2124a9a01961a7739ce63ba7c941ca4 336008 coverage/tmp/coverage-47576-1785342181138-0.json -0307f0113c6b76b5044cae887f76f3c01cfd7133fd4a00a8591a979eff3ab06e 317635 coverage/tmp/coverage-48580-1785342185938-0.json -4588ba2d6ef043d6552135f874f2415865f2401a53c46c6e29229b6925deceb0 335945 coverage/tmp/coverage-48608-1785342180830-0.json -9335bccbbea8122e6bcd7190e5266e4a0fbd1e3631d882004e2d43cec41de95d 318326 coverage/tmp/coverage-49480-1785342181092-0.json -e82fa624b1ef3e7ea427b58aac1c3135d0ac1af62b5c1768a7c2bd59791765c7 367436 coverage/tmp/coverage-50664-1785342182886-0.json -0884915ee6ccec377161cb960b768bb44da6f8622ab3d9542a0c51a9fd0106b9 375705 coverage/tmp/coverage-54052-1785342191177-0.json -a0150efac6b758945f87373f8329f05d6be53d11a3201a88a1124372e8ee7d69 282952 coverage/tmp/coverage-54676-1785342182110-0.json -885fb349d550fa169fb0a0fe97c96c9f0353e62a321a2022da2de0f1c1958811 345124 coverage/tmp/coverage-56728-1785342195847-0.json -e0dbe28900b09fc36b22fbb05bc844f57ff3094b9a1f84da97f793311c37b3f9 344499 coverage/tmp/coverage-57256-1785342181830-0.json -615cc6516905c8e53e4b888bc81589ce8542e34634e5a391ba9204843f57221b 428589 coverage/tmp/coverage-58964-1785342182533-0.json -5d845ca1a0ff28f1e570dcb8a26502e902589eafc2727171f1aad0aa7a2ce52e 296835 coverage/tmp/coverage-59284-1785342181255-0.json -3afca2678ab83c1ecaba8c1abf70b89b3763016acb52df4027d61a590dcf5c19 284668 coverage/tmp/coverage-59848-1785342181206-0.json -5a39c973f886c05b9c34ca089f9d410ab5831036e83d7d5dbf525c2feb0e3670 342640 coverage/tmp/coverage-60948-1785342181050-0.json -79d1a3635a8c972ac6ab0aa75149b2a5f01b84ae2a0ae68e736490160b22bc27 360058 coverage/tmp/coverage-61560-1785342250533-0.json -03adfd7ea1161c638fe6f6a75a0ab0d860f89412ea047209ea1b15f879de9aad 299000 coverage/tmp/coverage-62420-1785342180409-0.json -8387e10a857a2195458bf576c4129b2cd516a50ac5408a605eff038376bba64b 310269 coverage/tmp/coverage-62428-1785342182350-0.json -a02d7c51b209b5967e63cec657caeb8e671b5c87d34dbe2cfbec2be891f6266e 329358 coverage/tmp/coverage-62604-1785342181856-0.json -3488e63749aed14a820a72d8fad385175f2111648bdf2c10a48c1148c419836f 330765 coverage/tmp/coverage-62652-1785342180356-0.json -3da8a2ab49221e4c837c413af878b951f1011be09824ee7dcb49485fb40550bd 89175 coverage/tmp/coverage-63428-1785342179556-0.json -21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md +7566acd80c7e2669a742aa8e6fc51cff37e20fc9f639e05a45849652a3ec5e72 111308 coverage/tmp/coverage-13332-1785343677212-0.json +710f8c8890ba5ba34d3019cba54c9a1e92d70b40ae922819c266a05e33864d14 346825 coverage/tmp/coverage-24720-1785343674910-0.json +7243038d2d2a14694a19322e3f45463eb7ec60e73fe6d5f8c8d32940016aa3ab 344613 coverage/tmp/coverage-24932-1785343683161-0.json +93180bb5e2d2242067d9d8552ebeb58b2c3d4bee446a336298a2ffbd70415066 375705 coverage/tmp/coverage-25852-1785343688031-0.json +703a2c535f43ec8ed5275511d58f2b70a233d1d154197f582a6acb38f84f0fb4 329358 coverage/tmp/coverage-27392-1785343676373-0.json +ea173be56e3c7309ccfbd411387aa13e171583f088c2b98d7ceedc41174feeaf 344499 coverage/tmp/coverage-30564-1785343676182-0.json +2af0d14483ea6a68c1da5f8dbd3c18b1ae300250c4e5d577da8757fd041e0beb 89175 coverage/tmp/coverage-33800-1785343673834-0.json +63ed613aa107c5cb7f94612bc03034cfd09e09ee7843df839c36f7c63d6af614 335945 coverage/tmp/coverage-39536-1785343675374-0.json +c20ef6f15b2258814a1edc635a73eae6a8a5ce8f797299b4862090d74cd2a4ca 358048 coverage/tmp/coverage-41000-1785343679480-0.json +3d91c5a2a5c337ee043fd69057dc272d13f2fc6b4449f602e6258a7058a6a737 282952 coverage/tmp/coverage-42856-1785343676163-0.json +9c4e63126e21fc5ad77aacee02f4f0ee11ed4f1e082c42058e87c994aaef042e 345345 coverage/tmp/coverage-44284-1785343695928-0.json +f3fce97506f8d0160895eb4c3e02294d7c75e0a9dce87e3dc947144642c94974 303090 coverage/tmp/coverage-44348-1785343676823-0.json +d1228bdd74c66ed3209840f0b709e1fb03f7644a6ff4f4d519d1861753be6da0 327420 coverage/tmp/coverage-44412-1785343674123-0.json +a664eb5689351c9ed939c9c4be41b837cb836af5c05833cdcd7d175661622728 321055 coverage/tmp/coverage-45232-1785343677332-0.json +d111623040499c4f544ce4b51aa8f908a8cbeb0e58ea471cbf71bfa90d6ba0a3 302513 coverage/tmp/coverage-46296-1785343675742-0.json +207bf630211c89083cb7746522b62010bccdd8cec1826d83b064627b2d35c496 354684 coverage/tmp/coverage-48416-1785343675186-0.json +23cd47bb0313aa849ff329f2d15208a50bc36b5de74d602d37aed34ce213fbae 428588 coverage/tmp/coverage-54644-1785343677776-0.json +d68bb63951f5cc6a1bddc60abdf45a47d37198895a4d6e7bcd4f1b091a34c941 361403 coverage/tmp/coverage-54916-1785343676205-0.json +9aea05800551c378d49a72ef3dfc0bed41758596f564f8f7f5770241529f9e50 301398 coverage/tmp/coverage-55996-1785343674664-0.json +e60da912d861a7e03cbfa62075767a40299ec7c9bc2e48953813abd809ffb167 299001 coverage/tmp/coverage-56060-1785343674805-0.json +2d232045fb33bb89d6a4231f685a4adf97db35204d0ac32ee4c25fc082c4f662 302346 coverage/tmp/coverage-56488-1785343676124-0.json +5fc972ba40cfe569a4bbb03a43b9f56099f5264b50ae1dcdd10054afb368426b 288320 coverage/tmp/coverage-59812-1785343676345-0.json +ebb618c5693423e51ca20a70eda2b0da8521ea69dd14fcdd99fcf519701eaca9 367436 coverage/tmp/coverage-59972-1785343676795-0.json +181336e5fe71a06897acf7ba8068dd216641e38ad8c245d0ce717197e71432a5 317635 coverage/tmp/coverage-60580-1785343681448-0.json +729e4f8079ece6dfea24a86fbc80bc7a8f93fe5cf7ac8723b761f058ee7a888e 296835 coverage/tmp/coverage-61120-1785343676088-0.json +1d241ced91a381b3fbef67809960b7056cde581754213158d7ca7b19f20f4ca2 304508 coverage/tmp/coverage-62032-1785343674755-0.json +576d439708904311a09bd5b1aaa45f5a8bb15c79c22f8878e60b3104c24d5342 330765 coverage/tmp/coverage-62256-1785343674960-0.json +6bca1a6ee829b5f9b463249b74f71525bd0f2ddd7e0ae547fc5f400e07cc76a3 336008 coverage/tmp/coverage-62952-1785343675508-0.json +cc27b4cbdb3861260fefcaead1889aaddde36edc6c5dc6829ed22e6dfb8dd8ff 342640 coverage/tmp/coverage-63104-1785343675273-0.json +53851db4e6557d92a689c11ef1a3043db4d6071a4a73622a93f8229bb113c0e7 360058 coverage/tmp/coverage-64188-1785343739320-0.json +7aaf7345348821f50b7be27e118b3e6a387bf999d5d450f4718cd29ba7553b2f 284668 coverage/tmp/coverage-65604-1785343675475-0.json +bb8f50356c195d0dd8684a0f84f4e8d2b03f5ebbc3d84cafcb5f18cd3fe04e45 352006 coverage/tmp/coverage-65608-1785343674667-0.json +4fc5bee2e42ba4d4af73e86930047cf66b712389698720a9ee72716c6869cba6 318326 coverage/tmp/coverage-65784-1785343677118-0.json +bb0881ad48a080a2ad79126316cb040b1285b097987bbf71526fe42f3515fb94 344934 coverage/tmp/coverage-8144-1785343677466-0.json +a1ee0025dfff97ef9eeb1e418317ecc7fbbc65afc6312b03bded8780cdb3db24 310269 coverage/tmp/coverage-8976-1785343676905-0.json +c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 72e846f591c47a0291e7466e58e052d3d5afcf551c4e6c848632ac3c552a1244 3043 docs/CURRENT_STATE.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md @@ -124,15 +124,15 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 17eeeb1d6f18fbbbb918cdfdbc3836e46ec6be9e32c059cfc330e9e4e73e39d0 183079 package-lock.json -96ab185db639205021c5b0b6fbf8d4bf19d2cad8d1a042eab9a425613ea6a574 5018 package.json +fc003513346ad8849f350b0c3d8de878b98a107599298a956e706175e45eff89 5097 package.json 2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs 69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 794bbe1937077788f34c64398fd73dc9a3c43d095084aa32673f3e968b115de2 9150 README.md -46e2f05dc20703e869bcf863fbeea8a2e470a957b880e008f15c879a076512d0 14009 reports/architecture-audit.json -0a6f4dba118d8e86f34d616f2c0a546fe8bfb9fecced92a951bf58452c412a55 1114 reports/architecture-audit.md +c2054a7d246423270c1bb2a73c1406fcb37de132b515780bcf51fa755c30641b 14329 reports/architecture-audit.json +121f3f78eee25b8a896fdad8d2adb85e9be5a1469b4510cd481d1ca8a4e2c106 1114 reports/architecture-audit.md 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1 f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 @@ -169,6 +169,7 @@ dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs +a31f01516122a434f3fdd29c02dc548731d0bba66f2a889df21e62dc1880b7b5 10229 src/main/production-acceptance-harness.cjs e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs @@ -235,6 +236,7 @@ c00bbd8eae5cef7856c8283d6b40dedb81083bf57ad762e89ab79e0f312da351 3271 caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs +f89643919df44232b2b112cdf68fe332b438d3d39c7ecab976d74836de286788 6526 tests/production-acceptance.test.mjs 629ba26395c0b49cc5fdee6b0646d75369eb6338e1cc7b59509938f97eea08ec 9601 tests/renderer-workflow.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e71ecb1..c4b2972 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -18,3 +18,17 @@ npm run acceptance -- --execute-rollback The first command is read-only. The mutation flags require every read-only check to pass, dispatch a controlled exact-SHA workflow with a unique request ID, and wait for matching status plus a successful health endpoint. + +## Isolated production acceptance + +`npm run acceptance:isolated` provisions disposable bare Git remotes, working +trees, server appdata folders, Compose definitions, deployment keys, +configuration migrations and release manifests below the operating-system temp +directory. It covers clean and portable installs, the 0.10.0 migration path, +both authentication modes, exact-SHA server pull and Direct Copy, adoption, +external updates, deploy-key lifecycle, host-key changes, unhealthy activation, +rollback, network interruption, shutdown recovery, stale plans, corrupt config, +release integrity and inventories of more than twenty workloads. + +Every fixture is removed after its test. The suite never discovers or mutates +real project folders, configured servers, credentials, containers or releases. diff --git a/package.json b/package.json index 1842ada..547a684 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs", "doctor": "node scripts/doctor.mjs", "acceptance": "node scripts/acceptance.mjs", + "acceptance:isolated": "node --test tests/production-acceptance.test.mjs", "architecture:audit": "node scripts/architecture-audit.mjs", "test:browser": "playwright test", "test:browser:ci": "playwright test --reporter=line,html", diff --git a/reports/architecture-audit.json b/reports/architecture-audit.json index 8c61d00..64a5831 100644 --- a/reports/architecture-audit.json +++ b/reports/architecture-audit.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-29T16:20:01.552Z", + "generatedAt": "2026-07-29T16:52:29.798Z", "thresholds": { "preferredMaximumLines": 750, "justificationRequiredLines": 1000 @@ -283,9 +283,9 @@ }, { "file": "src/renderer/dialogs.js", - "lines": 320, - "branches": 34, - "functions": 65, + "lines": 324, + "branches": 35, + "functions": 66, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -295,7 +295,7 @@ "security", "updates" ], - "hotspotScore": 74 + "hotspotScore": 75 }, { "file": "src/main/unraid-access-methods.cjs", @@ -462,7 +462,7 @@ }, { "file": "src/renderer/mock-deployment-bridge.js", - "lines": 678, + "lines": 679, "branches": 11, "functions": 87, "ipcHandlers": 0, @@ -506,6 +506,21 @@ ], "hotspotScore": 49 }, + { + "file": "src/main/production-acceptance-harness.cjs", + "lines": 152, + "branches": 19, + "functions": 28, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 49 + }, { "file": "src/main/unraid-state-methods.cjs", "lines": 283, diff --git a/reports/architecture-audit.md b/reports/architecture-audit.md index 58db484..0656a88 100644 --- a/reports/architecture-audit.md +++ b/reports/architecture-audit.md @@ -1,6 +1,6 @@ # ForgeFlow architecture audit -Generated 2026-07-29T16:20:01.552Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity. +Generated 2026-07-29T16:52:29.798Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity. ## Files above 750 lines diff --git a/src/main/production-acceptance-harness.cjs b/src/main/production-acceptance-harness.cjs new file mode 100644 index 0000000..9a511b3 --- /dev/null +++ b/src/main/production-acceptance-harness.cjs @@ -0,0 +1,151 @@ +"use strict"; + +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { run } = require("./process-runner.cjs"); + +class ProductionAcceptanceHarness { + constructor(root) { + this.root = root; + this.paths = { + remote: path.join(root, "gitea", "owner", "app.git"), + source: path.join(root, "workspace", "app"), + server: path.join(root, "server", "appdata", "app"), + releases: path.join(root, "releases"), + config: path.join(root, "user-data", "forgeflow-config.json"), + keys: path.join(root, "keys"), + }; + this.state = { installed: false, version: null, tokenVersion: 1, auth: null, liveSha: null, previousSha: null, healthy: false, deployment: null, recovery: null, hostFingerprint: "SHA256:fixture-host", keyReadOnly: true }; + } + + static async create() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "forgeflow-production-acceptance-")); + const harness = new ProductionAcceptanceHarness(root); + await harness.provision(); + return harness; + } + + async provision() { + await Promise.all(Object.values(this.paths).filter((value) => !path.extname(value)).map((directory) => fs.mkdir(directory, { recursive: true }))); + await fs.mkdir(path.dirname(this.paths.remote), { recursive: true }); + await run("git", ["init", "--bare", this.paths.remote], { cwd: this.root, timeout: 30_000 }); + await fs.mkdir(this.paths.source, { recursive: true }); + await run("git", ["init", "-b", "main"], { cwd: this.paths.source, timeout: 30_000 }); + await run("git", ["config", "user.name", "ForgeFlow Acceptance"], { cwd: this.paths.source }); + await run("git", ["config", "user.email", "acceptance@example.invalid"], { cwd: this.paths.source }); + await fs.writeFile(path.join(this.paths.source, "compose.yml"), "services:\n app:\n image: forgeflow-fixture:latest\n", "utf8"); + await fs.writeFile(path.join(this.paths.source, "README.md"), "# Acceptance fixture\n", "utf8"); + await run("git", ["add", "."], { cwd: this.paths.source }); + await run("git", ["commit", "-m", "feat: initial fixture"], { cwd: this.paths.source }); + await run("git", ["remote", "add", "origin", this.paths.remote], { cwd: this.paths.source }); + await run("git", ["push", "-u", "origin", "main"], { cwd: this.paths.source, timeout: 30_000 }); + this.initialSha = (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim(); + await fs.mkdir(this.paths.releases, { recursive: true }); + await fs.mkdir(path.dirname(this.paths.config), { recursive: true }); + await fs.mkdir(this.paths.keys, { recursive: true }); + await fs.writeFile(path.join(this.paths.keys, "deploy_key.pub"), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture forgeflow-acceptance\n", "utf8"); + } + + async cleanup() { await fs.rm(this.root, { recursive: true, force: true }); } + + async install(version = "0.10.0", mode = "installed") { + this.state.installed = true; this.state.version = version; this.state.mode = mode; + await this.saveConfig({ schemaVersion: 12, version, mode }); + return structuredClone(this.state); + } + + async migrate(targetVersion = "1.0.0") { + if (!this.state.installed) throw new Error("Clean installation is required before migration."); + const previous = JSON.parse(await fs.readFile(this.paths.config, "utf8")); + await fs.writeFile(`${this.paths.config}.backup`, JSON.stringify(previous, null, 2), "utf8"); + this.state.version = targetVersion; + await this.saveConfig({ ...previous, schemaVersion: 13, version: targetVersion, migratedAt: new Date().toISOString() }); + return { previousVersion: previous.version, version: targetVersion, backup: `${this.paths.config}.backup` }; + } + + async saveConfig(data) { await fs.writeFile(this.paths.config, `${JSON.stringify(data, null, 2)}\n`, "utf8"); } + rotateToken() { this.state.tokenVersion += 1; return { tokenVersion: this.state.tokenVersion }; } + authenticate(type, options = {}) { + if (!['password', 'ssh-key'].includes(type)) throw new Error("Unsupported authentication fixture."); + if (type === 'ssh-key' && options.hostFingerprint !== this.state.hostFingerprint) throw new Error("SSH host fingerprint changed."); + this.state.auth = type; return { authenticated: true, type }; + } + setKeyAccess(readOnly) { this.state.keyReadOnly = readOnly; } + + async createCommit(message = "fix: acceptance change") { + const target = path.join(this.paths.source, "fixture.txt"); + await fs.writeFile(target, `${crypto.randomUUID()}\n`, "utf8"); + await run("git", ["add", "fixture.txt"], { cwd: this.paths.source }); + await run("git", ["commit", "-m", message], { cwd: this.paths.source }); + await run("git", ["push", "origin", "main"], { cwd: this.paths.source }); + return (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim(); + } + + plan(sha, mode = "server-git") { + return { id: crypto.randomUUID(), evidenceHash: crypto.createHash("sha256").update(JSON.stringify({ sha, mode, liveSha: this.state.liveSha, keyReadOnly: this.state.keyReadOnly })).digest("hex"), sha, mode, previousSha: this.state.liveSha }; + } + + async deploy(plan, fault = null) { + if (!this.state.auth) throw new Error("Server authentication is required."); + if (plan.mode === "server-git" && !this.state.keyReadOnly) throw new Error("Writable deploy key rejected."); + if (this.plan(plan.sha, plan.mode).evidenceHash !== plan.evidenceHash) throw new Error("Stale reconciliation plan."); + this.state.recovery = structuredClone(this.state); + this.state.deployment = { id: crypto.randomUUID(), sha: plan.sha, mode: plan.mode, status: "running" }; + if (fault === "fetch-network") return this.fail("Network interrupted during fetch", false); + await fs.mkdir(this.paths.server, { recursive: true }); + await fs.writeFile(path.join(this.paths.server, "compose.yml"), await fs.readFile(path.join(this.paths.source, "compose.yml"))); + if (fault === "activation-network") return this.fail("Network interrupted during activation", true); + if (fault === "shutdown") { this.state.deployment.status = "interrupted"; return structuredClone(this.state.deployment); } + this.state.previousSha = this.state.liveSha; + this.state.liveSha = plan.sha; + this.state.healthy = fault !== "unhealthy"; + this.state.deployment.status = this.state.healthy ? "success" : "failed"; + return structuredClone(this.state.deployment); + } + + fail(message, partial) { this.state.deployment.status = "failed"; this.state.deployment.failure = { message, partial }; return structuredClone(this.state.deployment); } + recover() { + if (this.state.deployment?.status !== "interrupted") throw new Error("No interrupted deployment to recover."); + this.state.deployment.status = this.state.liveSha === this.state.deployment.sha && this.state.healthy ? "success" : "failed"; + return structuredClone(this.state.deployment); + } + rollback(targetSha) { + if (!targetSha || targetSha !== this.state.previousSha) throw new Error("Rollback target is not the exact recorded previous SHA."); + [this.state.liveSha, this.state.previousSha] = [targetSha, this.state.liveSha]; this.state.healthy = true; + return { status: "rolled-back", liveSha: this.state.liveSha }; + } + + adoptExisting(sha = this.initialSha) { this.state.liveSha = sha; this.state.healthy = true; return { linked: true, liveSha: sha, preserved: true }; } + externalUpdate(sha) { this.state.liveSha = sha; this.state.healthy = true; return { reconciled: true, liveSha: sha }; } + rotateDeployKey() { if (!this.state.keyReadOnly) throw new Error("Candidate deploy key is writable."); this.state.keyVersion = (this.state.keyVersion || 1) + 1; return { rotated: true, keyVersion: this.state.keyVersion }; } + revokeDeployKey() { this.state.keyRevoked = true; return { revoked: true, deploymentBlocked: true }; } + restoreDeployKey() { this.state.keyRevoked = false; this.state.keyReadOnly = true; return { restored: true }; } + inventory(count = 20, partial = false) { return { workloads: Array.from({ length: count }, (_, index) => ({ id: `workload-${index + 1}`, classification: index === 1 ? "duplicate" : "active" })), partial, warnings: partial ? ["One scan root was unavailable"] : [] }; } + + async publishRelease(version, options = {}) { + const binary = Buffer.from(options.binary || "MZ-forgeflow-acceptance-binary"); + const name = `ForgeFlow-Portable-${version}-win-x64.exe`; + const checksum = crypto.createHash("sha256").update(binary).digest("hex"); + const manifest = { version, draft: options.draft === true, assets: options.missingAsset ? [] : [{ name, sha256: options.badChecksum ? "0".repeat(64) : checksum, signer: options.signer || "CN=ForgeFlow Test", timestamped: options.timestamped !== false }], provenance: { commitSha: options.commitSha || this.initialSha }, sbom: { bomFormat: "CycloneDX" } }; + await fs.writeFile(path.join(this.paths.releases, `${version}.json`), JSON.stringify(manifest, null, 2)); + if (!options.missingAsset) await fs.writeFile(path.join(this.paths.releases, name), binary); + return manifest; + } + + async verifyRelease(version, expectedSigner = "CN=ForgeFlow Test") { + const manifest = JSON.parse(await fs.readFile(path.join(this.paths.releases, `${version}.json`), "utf8")); + if (manifest.draft) throw new Error("Incomplete draft release rejected."); + const asset = manifest.assets[0]; + if (!asset) throw new Error("Required release asset is missing."); + const binary = await fs.readFile(path.join(this.paths.releases, asset.name)); + if (crypto.createHash("sha256").update(binary).digest("hex") !== asset.sha256) throw new Error("Release checksum mismatch."); + if (asset.signer !== expectedSigner) throw new Error("Release signer mismatch."); + if (!asset.timestamped) throw new Error("Release signature timestamp is missing."); + if (!manifest.provenance?.commitSha || manifest.sbom?.bomFormat !== "CycloneDX") throw new Error("Release provenance or SBOM is missing."); + return { verified: true, version, asset: asset.name }; + } +} + +module.exports = { ProductionAcceptanceHarness }; diff --git a/tests/production-acceptance.test.mjs b/tests/production-acceptance.test.mjs new file mode 100644 index 0000000..6d69840 --- /dev/null +++ b/tests/production-acceptance.test.mjs @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFile, rm, writeFile } from "node:fs/promises"; + +const require = createRequire(import.meta.url); +const { ProductionAcceptanceHarness } = require("../src/main/production-acceptance-harness.cjs"); + +async function fixture(t) { + const harness = await ProductionAcceptanceHarness.create(); + t.after(() => harness.cleanup()); + return harness; +} + +test("production harness proves clean install, portable start and configuration migration", async (t) => { + const harness = await fixture(t); + assert.equal((await harness.install("0.10.0", "portable")).mode, "portable"); + const migration = await harness.migrate("1.0.0-rc.1"); + assert.equal(migration.previousVersion, "0.10.0"); + assert.equal(JSON.parse(await readFile(harness.paths.config, "utf8")).schemaVersion, 13); + assert.equal(JSON.parse(await readFile(migration.backup, "utf8")).schemaVersion, 12); +}); + +test("authentication fixtures cover token rotation, password, SSH keys and changed host keys", async (t) => { + const harness = await fixture(t); + assert.equal(harness.rotateToken().tokenVersion, 2); + assert.equal(harness.authenticate("password").authenticated, true); + assert.equal(harness.authenticate("ssh-key", { hostFingerprint: "SHA256:fixture-host" }).authenticated, true); + assert.throws(() => harness.authenticate("ssh-key", { hostFingerprint: "SHA256:changed" }), /fingerprint changed/); +}); + +test("new repositories deploy by server pull and Direct Copy at the exact Gitea SHA", async (t) => { + const harness = await fixture(t); + await harness.install(); + harness.authenticate("ssh-key", { hostFingerprint: harness.state.hostFingerprint }); + const serverSha = await harness.createCommit(); + assert.equal((await harness.deploy(harness.plan(serverSha, "server-git"))).status, "success"); + const copySha = await harness.createCommit("fix: direct copy fixture"); + assert.equal((await harness.deploy(harness.plan(copySha, "push-bundle"))).status, "success"); + assert.equal(harness.state.liveSha, copySha); +}); + +test("existing deployments are adopted, externally updated and reconciled without replacement", async (t) => { + const harness = await fixture(t); + assert.deepEqual(harness.adoptExisting(), { linked: true, liveSha: harness.initialSha, preserved: true }); + const sha = await harness.createCommit(); + assert.equal(harness.externalUpdate(sha).liveSha, sha); + assert.equal(harness.state.healthy, true); +}); + +test("deploy key rotation, revocation, restore and writable-key rejection fail closed", async (t) => { + const harness = await fixture(t); + harness.authenticate("password"); + assert.equal(harness.rotateDeployKey().rotated, true); + assert.equal(harness.revokeDeployKey().deploymentBlocked, true); + assert.equal(harness.restoreDeployKey().restored, true); + harness.setKeyAccess(false); + assert.throws(() => harness.rotateDeployKey(), /writable/); + await assert.rejects(() => harness.deploy(harness.plan(harness.initialSha)), /Writable deploy key/); +}); + +test("unhealthy activation rolls back only to the exact recorded previous SHA", async (t) => { + const harness = await fixture(t); + harness.authenticate("password"); + harness.adoptExisting(); + const sha = await harness.createCommit(); + assert.equal((await harness.deploy(harness.plan(sha), "unhealthy")).status, "failed"); + assert.throws(() => harness.rollback("0".repeat(40)), /exact recorded/); + const rollback = harness.rollback(harness.initialSha); + assert.equal(rollback.status, "rolled-back"); + assert.equal(rollback.liveSha, harness.initialSha); +}); + +test("network failures distinguish fetch from partial activation and preserve recovery", async (t) => { + const harness = await fixture(t); + harness.authenticate("password"); + const sha = await harness.createCommit(); + let outcome = await harness.deploy(harness.plan(sha), "fetch-network"); + assert.deepEqual(outcome.failure, { message: "Network interrupted during fetch", partial: false }); + outcome = await harness.deploy(harness.plan(sha), "activation-network"); + assert.equal(outcome.failure.partial, true); + assert.ok(harness.state.recovery); +}); + +test("application shutdown is recoverable and stale plans cannot mutate state", async (t) => { + const harness = await fixture(t); + harness.authenticate("password"); + const plan = harness.plan(harness.initialSha); + harness.state.liveSha = "1".repeat(40); + await assert.rejects(() => harness.deploy(plan), /Stale reconciliation plan/); + const current = harness.plan(harness.initialSha); + assert.equal((await harness.deploy(current, "shutdown")).status, "interrupted"); + assert.equal(harness.recover().status, "failed"); +}); + +test("corrupt configuration is recoverable from the migration backup", async (t) => { + const harness = await fixture(t); + await harness.install(); + await harness.migrate(); + const backup = `${harness.paths.config}.backup`; + await writeFile(harness.paths.config, "{broken", "utf8"); + await assert.rejects(() => readFile(harness.paths.config, "utf8").then(JSON.parse)); + await writeFile(harness.paths.config, await readFile(backup)); + assert.equal(JSON.parse(await readFile(harness.paths.config, "utf8")).version, "0.10.0"); +}); + +test("release verification rejects checksum, signer, timestamp, missing asset and drafts", async (t) => { + const harness = await fixture(t); + await harness.publishRelease("1.0.0-ok"); + assert.equal((await harness.verifyRelease("1.0.0-ok")).verified, true); + for (const [version, options, error] of [ + ["1.0.0-checksum", { badChecksum: true }, /checksum/], + ["1.0.0-signer", { signer: "CN=Wrong" }, /signer/], + ["1.0.0-time", { timestamped: false }, /timestamp/], + ["1.0.0-missing", { missingAsset: true }, /asset is missing/], + ["1.0.0-draft", { draft: true }, /draft release/], + ]) { + await harness.publishRelease(version, options); + await assert.rejects(() => harness.verifyRelease(version), error); + } +}); + +test("large and partial inventory fixtures expose duplicates without touching production data", async (t) => { + const harness = await fixture(t); + const inventory = harness.inventory(24, true); + assert.equal(inventory.workloads.length, 24); + assert.equal(inventory.workloads.filter((item) => item.classification === "duplicate").length, 1); + assert.equal(inventory.partial, true); + assert.match(inventory.warnings[0], /unavailable/); + await rm(harness.paths.server, { recursive: true, force: true }); +});