diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index d708647..902687d 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,7 +1,7 @@ ForgeFlow 0.10.12 source manifest SHA-256 BYTES PATH (The manifest excludes itself, dependencies and generated release artifacts.) -cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml +61f37822ae5502219a38b2eaf23fdcb611875f0e675efb4abe6157c9f072c0cc 937 .gitea/workflows/quality.yml 4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml @@ -95,7 +95,7 @@ b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md 4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md -4bffda594058697345569d937d7a524f094ac85a0f338f0ef18fcf3f94d8c299 1292 eslint.config.js +73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 eslint.config.js c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml 4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml 577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy @@ -107,15 +107,15 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 d7cae22a646c8f06f339db799f2b9921ba1799cdc44e5d05578c04aaace103ab 14357 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 198a8a51705fcea2ddf716558a49e06df5a8181c5476709c474d183b64890ca0 179808 package-lock.json -84fa9b490e0845b1788d4f58741d733f0afd5946db5487dfba03a6e3018890cc 5782 package.json +c579d4a9df6ca10c32a7b8168567bfdb333745438747430329a7a64041f1a4c4 5971 package.json 1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs 16d6c37007c6c14dbf541060535c594308758477b867fc2a190c2946d74022b9 12196 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 42e2f375c2fa9bea138428b85eb2bb93fe8bf6e4a5fc0cd728f2c6713a019df7 10315 README.md -8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json -5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md +cac1e82069c0addef5cfc1fdb0dfed0a6b00408dbfe0ea95b24ed12e9e73ff13 14947 reports/architecture-audit.json +5f41108efe28200ec629d640e75bdf3ef326da98970ea0b941cbbf7ee9a83c61 1343 reports/architecture-audit.md 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1 404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1 @@ -133,21 +133,22 @@ e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs -92856d698d0a5cc0a3e112e9dc05eb6de473809e9f82e7b08dd21f13f4ec1af8 32309 src/main/config-store.cjs +4ca0fd6e1084db8db67e0b1c1c2d54ca6d29fe76da4f322dc417db00ef91c3dc 33088 src/main/config-store.cjs 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs 86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs 7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs -e36ae11f6f60c9520a1f7b77b034091900d49306ff8674b5293eb95d679dcc5c 24273 src/main/deployment-service.cjs +ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs -0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs +665859c48be68e5f256b9fac9a4722390cf1154705c099778f36e0e0a6c07e27 35214 src/main/git-service.cjs e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs 3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs 3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs 2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs -f263ab067edea8dfeb962958d045ed4ab20b49cbeb965936f4ffa8c1dd0b7d59 25905 src/main/ipc.cjs -0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs +0e01dfeb9ad230ee13ee913e17b577b1d34507b9b436ebf849fa05312e470d46 24398 src/main/ipc.cjs +26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs +b80357dd1f0aa18022d92db85b6cc8f29bc691ef11f9a0e90b4886ae5e19c763 12543 src/main/ipc/deployment-handlers.cjs dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs 253d4f027de09e1e8fd46b40ef0da4eaf8350c0edfbf069b0a3429d712c7e8c4 15929 src/main/ipc/repository-handlers.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs @@ -155,18 +156,18 @@ dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs -fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs -e48ac82e3a618459170969a6370c8a2d667594cd0b1915ccece38ba3f7eda6df 10303 src/main/repository-service.cjs +c629efa832a806409954ab5a4986bf88606ce5fa5278bb1885a76222d1cb6faf 6441 src/main/repository-monitor.cjs +6393583911263575c6e2a19d9baab6e638cce90252c386b0a5144f2fb6f81f15 12154 src/main/repository-service.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs -afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs -5394d16c1ceceef700a4b2a47cda088713e24c2e42a4bf4f86b1fa6006e796a2 25877 src/main/unraid-access-methods.cjs +5830cf038c2404272b743657bfb4ce57c700e2f17b4f063d06afb80c9130bcba 14863 src/main/ssh-service.cjs +19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs 5ba5a90ad9b7a0d05dd64fa64968cbdd7271bec4df6971f9685a9dd411e4fad4 9536 src/main/unraid-deploy-key-host.cjs 6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs -b8a6ef72cfd5c5405e3f1a48848b5ef1575ca82ebbe8cad816dff0dc7335c8ec 17184 src/main/unraid-deployment-service.cjs +673b1692e7c2b5197545df98750b5d048bddf44206263e25be4f17d9bf900e2c 17208 src/main/unraid-deployment-service.cjs a2fa253bb2664ad7e430d7a4cd4c4434b89e6c54c19c7b9e4f53fe08485339d3 42652 src/main/unraid-inventory-methods.cjs 2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs -9f92b5716ff72e676218863ca8f9678edf5d61db8a9cef5bdfe579cc4446d9c8 11783 src/main/unraid-state-methods.cjs +4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs a387ac9735624ef89d964ea59e035dae256b9915ba2cee22ae5cbc2894f5808a 22000 src/main/update-service.cjs b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js @@ -175,21 +176,21 @@ d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js -d399b9b277350225ba98634f595032eae4412a07071faea69185ba5f1c0f2330 26597 src/renderer/app.js +984da4d93acc7461078b70c1c767f4843ef22fa5f3e1d947a1f3d5a5175ed20d 26601 src/renderer/app.js 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png -1a577af2459715cf38d49d118da8c5f897a7ff1d3eb42ad41f121675eb871732 48389 src/renderer/dialogs.js +02ae73c50856c827ed13e929396643699a29ac03114ceef1a8706e2e172578c5 51148 src/renderer/dialogs.js eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js -a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html +5b0701cea836286325a9653e265eea05547b47019549efc52edd66749625a10d 1479 src/renderer/index.html 06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js 870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js 81b0155dd51c7bc8871fd29bd7fdb28857cf11a86ed6d6953238c04c2774800c 20033 src/renderer/mock-repository-bridge.js 94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js 21b8f3228bddd6da06613857763282d1f88f4eeab3d063f3a63395d7b567f2e4 80004 src/renderer/styles.css -8bc794e191aa22a13cbbc89c9b3882e0c3070e1365a7c02cfe381a62d81877d9 101549 src/renderer/views.js -0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs +48dbcdeb844ae991f02ee38f1a6993df27dcd7efe032c3dbc529ece315c68dfb 102537 src/renderer/views.js +c7c204360aba6a9a2cf1b0cd80f7aa13ddd212ecea0ed8b385e4c4cc40bfbefc 1383 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs @@ -202,11 +203,14 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1 f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs -e8802b78b94af7245f7752e0a5f8697064aad2aae8bd002195ae052d69e59670 11619 tests/browser/forgeflow.spec.mjs -454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs +4018fa3ba329b02af38efc67e3e03cef4ff8f422eddacb47a18979fedd04e1c7 15344 tests/browser/forgeflow.spec.mjs +0d146d3b3428522df02bfacf80332ca6db065cb05666c0e02114885b8d51dea1 5858 tests/clone-target.test.mjs ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs +144c8e217a334cd69f502938d944e0f2dac61703d5df47e287b9ed542918c779 8129 tests/dependency-wiring.test.mjs +8ca5b62c691637f1ef35d7acff776f27652a6963bd7414b3cddf764cfd7db48d 9839 tests/deploy-key-host.test.mjs b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs +57d93ac176cc6ff0850b38fcc6ec5be0e8eaca7e457a5a188bf86fa4a38d5b45 24378 tests/deployment-operations.test.mjs 1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs @@ -226,8 +230,8 @@ caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs d4980aa7d2d364793f38771011383348be472d5447b6ee88a4d9e5f3a3b3ffa9 9911 tests/renderer-workflow.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs -62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs -32056b36a7e78701e8b9253ea78646ab4ecd88c5788841a1459177417ecb2e08 10441 tests/repository-service.test.mjs +e3c6e0f5c9351fce2ccf96b8abb15de404a0f935633df0b5b1c3fefa207c723b 3993 tests/repository-monitor.test.mjs +5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs 12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs diff --git a/package.json b/package.json index e5d50c4..28fd46b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "demo": "node scripts/serve-demo.mjs", "test": "node --test tests/*.test.mjs", "lint": "eslint .", - "coverage": "c8 --check-coverage --lines 75 --functions 75 --branches 65 --statements 75 node --test tests/*.test.mjs", + "coverage": "c8 --check-coverage --lines 85 --functions 85 --branches 68 --statements 85 node --test tests/*.test.mjs && npm run coverage:modules", + "coverage:modules": "c8 report --check-coverage --per-file --include src/** --statements 60 --lines 60 --functions 50 --branches 36 --reporter=text-summary", "verify": "node scripts/verify.mjs", "dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs", "dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs", diff --git a/reports/architecture-audit.json b/reports/architecture-audit.json index 9545df6..1e9f510 100644 --- a/reports/architecture-audit.json +++ b/reports/architecture-audit.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-08-01T15:23:33.157Z", + "generatedAt": "2026-08-23T12:21:43.185Z", "thresholds": { "preferredMaximumLines": 750, "justificationRequiredLines": 1000 @@ -22,6 +22,22 @@ ], "hotspotScore": 131 }, + { + "file": "src/renderer/app.js", + "lines": 732, + "branches": 80, + "functions": 124, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 120 + }, { "file": "src/main/server-inventory.cjs", "lines": 578, @@ -38,31 +54,31 @@ "hotspotScore": 119 }, { - "file": "src/renderer/app.js", - "lines": 676, - "branches": 75, - "functions": 113, + "file": "src/main/git-service.cjs", + "lines": 687, + "branches": 111, + "functions": 118, + "ipcHandlers": 0, + "responsibilities": [ + "git", + "security" + ], + "hotspotScore": 111 + }, + { + "file": "src/main/unraid-inventory-methods.cjs", + "lines": 708, + "branches": 76, + "functions": 93, "ipcHandlers": 0, "responsibilities": [ "inventory", "deployment", "git", - "renderer", "security", "updates" ], - "hotspotScore": 115 - }, - { - "file": "src/main/git-service.cjs", - "lines": 632, - "branches": 98, - "functions": 109, - "ipcHandlers": 0, - "responsibilities": [ - "git" - ], - "hotspotScore": 98 + "hotspotScore": 106 } ], "mixedResponsibilityModules": [ @@ -81,6 +97,22 @@ ], "hotspotScore": 131 }, + { + "file": "src/renderer/app.js", + "lines": 732, + "branches": 80, + "functions": 124, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 120 + }, { "file": "src/main/server-inventory.cjs", "lines": 578, @@ -97,41 +129,23 @@ "hotspotScore": 119 }, { - "file": "src/renderer/app.js", - "lines": 676, - "branches": 75, - "functions": 113, + "file": "src/main/unraid-inventory-methods.cjs", + "lines": 708, + "branches": 76, + "functions": 93, "ipcHandlers": 0, "responsibilities": [ "inventory", "deployment", "git", - "renderer", "security", "updates" ], - "hotspotScore": 115 - }, - { - "file": "src/main/ipc.cjs", - "lines": 749, - "branches": 55, - "functions": 79, - "ipcHandlers": 27, - "responsibilities": [ - "inventory", - "deployment", - "git", - "ipc", - "renderer", - "security", - "updates" - ], - "hotspotScore": 105 + "hotspotScore": 106 }, { "file": "src/main/unraid-deployment-methods.cjs", - "lines": 577, + "lines": 583, "branches": 69, "functions": 31, "ipcHandlers": 0, @@ -145,10 +159,10 @@ "hotspotScore": 99 }, { - "file": "src/renderer/views.js", - "lines": 737, - "branches": 55, - "functions": 156, + "file": "src/renderer/dialogs.js", + "lines": 419, + "branches": 58, + "functions": 80, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -158,18 +172,35 @@ "security", "updates" ], - "hotspotScore": 95 + "hotspotScore": 98 }, { - "file": "src/main/unraid-inventory-methods.cjs", - "lines": 591, - "branches": 61, - "functions": 76, + "file": "src/renderer/views.js", + "lines": 749, + "branches": 56, + "functions": 159, "ipcHandlers": 0, "responsibilities": [ "inventory", "deployment", "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 96 + }, + { + "file": "src/main/ipc.cjs", + "lines": 694, + "branches": 51, + "functions": 70, + "ipcHandlers": 27, + "responsibilities": [ + "inventory", + "deployment", + "git", + "ipc", "security", "updates" ], @@ -204,11 +235,28 @@ ], "hotspotScore": 87 }, + { + "file": "main.cjs", + "lines": 463, + "branches": 37, + "functions": 56, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "ipc", + "renderer", + "security", + "updates" + ], + "hotspotScore": 87 + }, { "file": "src/renderer/actions/deployment-profile.js", - "lines": 402, + "lines": 407, "branches": 56, - "functions": 43, + "functions": 44, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -220,27 +268,25 @@ "hotspotScore": 86 }, { - "file": "main.cjs", - "lines": 443, - "branches": 33, - "functions": 53, + "file": "src/main/config-store.cjs", + "lines": 665, + "branches": 52, + "functions": 89, "ipcHandlers": 0, "responsibilities": [ "inventory", "deployment", "git", - "ipc", - "renderer", "security", "updates" ], - "hotspotScore": 83 + "hotspotScore": 82 }, { "file": "src/main/unraid-preflight-methods.cjs", - "lines": 594, - "branches": 38, - "functions": 42, + "lines": 623, + "branches": 40, + "functions": 47, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -250,22 +296,7 @@ "security", "updates" ], - "hotspotScore": 78 - }, - { - "file": "src/main/config-store.cjs", - "lines": 652, - "branches": 47, - "functions": 86, - "ipcHandlers": 0, - "responsibilities": [ - "inventory", - "deployment", - "git", - "security", - "updates" - ], - "hotspotScore": 77 + "hotspotScore": 80 }, { "file": "src/main/unraid-runtime-methods.cjs", @@ -297,27 +328,11 @@ ], "hotspotScore": 76 }, - { - "file": "src/renderer/dialogs.js", - "lines": 324, - "branches": 35, - "functions": 66, - "ipcHandlers": 0, - "responsibilities": [ - "inventory", - "deployment", - "git", - "renderer", - "security", - "updates" - ], - "hotspotScore": 75 - }, { "file": "src/main/unraid-access-methods.cjs", - "lines": 448, + "lines": 462, "branches": 43, - "functions": 42, + "functions": 41, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -359,7 +374,7 @@ }, { "file": "src/main/ssh-service.cjs", - "lines": 333, + "lines": 332, "branches": 41, "functions": 69, "ipcHandlers": 0, @@ -445,7 +460,7 @@ }, { "file": "src/main/ipc/deployment-handlers.cjs", - "lines": 285, + "lines": 286, "branches": 13, "functions": 38, "ipcHandlers": 24, @@ -493,7 +508,7 @@ }, { "file": "src/renderer/mock-deployment-bridge.js", - "lines": 699, + "lines": 701, "branches": 11, "functions": 87, "ipcHandlers": 0, @@ -539,9 +554,9 @@ }, { "file": "src/main/unraid-state-methods.cjs", - "lines": 283, - "branches": 15, - "functions": 20, + "lines": 294, + "branches": 16, + "functions": 21, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -550,7 +565,7 @@ "security", "updates" ], - "hotspotScore": 45 + "hotspotScore": 46 }, { "file": "src/main/preflight-service.cjs", @@ -566,6 +581,20 @@ ], "hotspotScore": 44 }, + { + "file": "src/main/repository-service.cjs", + "lines": 304, + "branches": 22, + "functions": 46, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 42 + }, { "file": "src/renderer/mock-bridge.js", "lines": 590, @@ -594,20 +623,6 @@ ], "hotspotScore": 33 }, - { - "file": "src/main/repository-service.cjs", - "lines": 200, - "branches": 12, - "functions": 32, - "ipcHandlers": 0, - "responsibilities": [ - "deployment", - "git", - "security", - "updates" - ], - "hotspotScore": 32 - }, { "file": "src/main/deployment-identity.cjs", "lines": 36, @@ -669,24 +684,23 @@ "ipcHotspots": [ { "file": "src/main/ipc.cjs", - "lines": 749, - "branches": 55, - "functions": 79, + "lines": 694, + "branches": 51, + "functions": 70, "ipcHandlers": 27, "responsibilities": [ "inventory", "deployment", "git", "ipc", - "renderer", "security", "updates" ], - "hotspotScore": 105 + "hotspotScore": 91 }, { "file": "src/main/ipc/deployment-handlers.cjs", - "lines": 285, + "lines": 286, "branches": 13, "functions": 38, "ipcHandlers": 24, diff --git a/reports/architecture-audit.md b/reports/architecture-audit.md index e3c802c..f9c0b90 100644 --- a/reports/architecture-audit.md +++ b/reports/architecture-audit.md @@ -1,6 +1,6 @@ # ForgeFlow architecture audit -Generated 2026-08-01T15:23:33.157Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity. +Generated 2026-08-23T12:21:43.185Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity. ## Files above 750 lines @@ -19,9 +19,10 @@ No findings. | File | Lines | Decisions | Functions | IPC handlers | Responsibilities | |---|---:|---:|---:|---:|---| | `src/renderer/actions/shell.js` | 518 | 101 | 86 | 0 | inventory, deployment, git, renderer, updates | +| `src/renderer/app.js` | 732 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates | | `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates | -| `src/renderer/app.js` | 676 | 75 | 113 | 0 | inventory, deployment, git, renderer, security, updates | -| `src/main/git-service.cjs` | 632 | 98 | 109 | 0 | git | +| `src/main/git-service.cjs` | 687 | 111 | 118 | 0 | git, security | +| `src/main/unraid-inventory-methods.cjs` | 708 | 76 | 93 | 0 | inventory, deployment, git, security, updates | ## Interpretation diff --git a/src/shared/clone-target.cjs b/src/shared/clone-target.cjs index ee1c058..3f39427 100644 --- a/src/shared/clone-target.cjs +++ b/src/shared/clone-target.cjs @@ -5,7 +5,11 @@ const path = require('node:path'); function cloneDirectoryName(remoteUrl) { const raw = String(remoteUrl || '').trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, ''); const segment = raw.split(/[\\/:]/).filter(Boolean).at(-1) || 'repository'; - return segment.replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-') || 'repository'; + const name = segment.replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-'); + // A name made only of dots is not a usable directory. Windows strips trailing + // dots, so "..." would resolve back to the project root itself and slip past + // the escape check in resolveCloneTarget below. + return !name || /^\.+$/.test(name) ? 'repository' : name; } function resolveCloneTarget(workspaceRoot, remoteUrl) { diff --git a/tests/clone-target.test.mjs b/tests/clone-target.test.mjs index 6f24f64..78770ba 100644 --- a/tests/clone-target.test.mjs +++ b/tests/clone-target.test.mjs @@ -26,6 +26,35 @@ test('resolves the automatic clone target inside the configured project root', ( assert.equal(plan.directoryName, 'portfolio'); }); +test('a clone target that would leave the project root is refused', () => { + const root = path.join(os.tmpdir(), 'forgeflow-projects'); + const resolved = path.resolve(root); + + // The escape guard inside resolveCloneTarget stays as a backstop, but no + // sanitised folder name can reach it any more: the name is a single path + // segment and a dots-only segment falls back to "repository". + for (const remote of ['..', '.', '../escape', '/', '', '....git', 'https://gitea.example.test/jens/....git']) { + const plan = resolveCloneTarget(root, remote); + assert.ok( + plan.target.startsWith(`${resolved}${path.sep}`) && plan.target !== resolved, + `${remote} resolved outside the project root: ${plan.target}`, + ); + } + for (const badRoot of ['', ' ', null, undefined]) { + assert.throws(() => resolveCloneTarget(badRoot, 'https://gitea.example.test/jens/app.git'), /project root is required/); + } +}); + +test('a folder name that sanitises away still produces a usable directory', () => { + // Windows strips trailing dots, so a dots-only name would land on the project + // root itself instead of a subdirectory. + assert.equal(cloneDirectoryName('https://gitea.example.test/jens/....git'), 'repository'); + assert.equal(cloneDirectoryName('..'), 'repository'); + assert.equal(cloneDirectoryName(''), 'repository'); + assert.equal(cloneDirectoryName('https://gitea.example.test/jens/app.git#readme'), 'app'); + assert.equal(cloneDirectoryName('https://gitea.example.test/jens/spaced name.git'), 'spaced-name'); +}); + test('clone target inspection accepts missing and empty destinations', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-')); t.after(() => fs.rm(root, { recursive: true, force: true })); diff --git a/tests/deploy-key-host.test.mjs b/tests/deploy-key-host.test.mjs new file mode 100644 index 0000000..1a78fb5 --- /dev/null +++ b/tests/deploy-key-host.test.mjs @@ -0,0 +1,194 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs"); + +const SERVER = { id: "unraid", basePath: "/mnt/user/appdata" }; +const REPOSITORY = { fullName: "Jens/Portfolio" }; + +// The host reaches the server through a single exec call, so capturing the script +// it sends is the only way to assert what actually happens to the key material. +function keyHost(stdout = "") { + const scripts = []; + const ssh = { + exec: async (serverId, command, options) => { + const encoded = command.match(/printf '%s' '([^']+)'/)?.[1] || ""; + scripts.push({ serverId, options, script: Buffer.from(encoded, "base64").toString("utf8") }); + return { stdout }; + }, + }; + return { host: new UnraidDeployKeyHost({ ssh }), scripts }; +} + +test("deploy-key storage is repository-scoped, deterministic and stays under the server base path", () => { + const { host } = keyHost(); + const first = host.paths(REPOSITORY, SERVER); + const again = host.paths({ fullName: "jens/portfolio" }, SERVER); + const other = host.paths({ fullName: "Jens/Other" }, SERVER); + + assert.deepEqual(first, again, "the same repository always resolves to the same directory"); + assert.notEqual(first.directory, other.directory, "a different repository never shares a key directory"); + for (const value of Object.values(first)) { + assert.ok(value.startsWith("/mnt/user/appdata/.forgeflow/git-credentials/"), value); + assert.ok(!value.includes("..")); + } + assert.ok(!first.directory.toLowerCase().includes("portfolio"), "the repository name is hashed, not embedded"); +}); + +test("the server pull remote is taken from the first usable SSH URL and refused when there is none", () => { + const { host } = keyHost(); + assert.equal( + host.remote({ ...REPOSITORY, localStatus: { remoteUrl: "https://gitea.example/Jens/Portfolio.git" }, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, {}), + "git@gitea.example:Jens/Portfolio.git", + "an HTTPS remote is skipped in favour of the SSH URL", + ); + assert.equal( + host.remote({ ...REPOSITORY }, { cloneUrl: "ssh://git@gitea.example:2222/Jens/Portfolio.git" }), + "ssh://git@gitea.example:2222/Jens/Portfolio.git", + ); + assert.throws( + () => host.remote({ ...REPOSITORY, sshUrl: "https://gitea.example/Jens/Portfolio.git" }, {}), + (error) => { + assert.equal(error.code, "SERVER_GIT_SSH_URL_REQUIRED"); + return true; + }, + ); +}); + +test("the Git SSH environment pins the scoped key and refuses an unknown host", () => { + const { host } = keyHost(); + const paths = host.paths(REPOSITORY, SERVER); + const environment = host.environment(paths); + + assert.match(environment, /IdentitiesOnly=yes/); + assert.match(environment, /BatchMode=yes/); + assert.match(environment, /StrictHostKeyChecking=yes/); + assert.ok(environment.includes(paths.knownHosts), "the pinned host key file is repository-scoped"); + assert.ok(environment.includes(paths.privateKey)); +}); + +test("a backup copies the current key material into a fresh recovery slot", async () => { + const publicKey = "ssh-ed25519 QkFL forgeflow"; + const { host, scripts } = keyHost( + `__FORGEFLOW_KEY_BACKUP__\nrecovery=/mnt/user/appdata/.forgeflow/git-credentials/abc/recovery/backup-1\npublicKey=${Buffer.from(publicKey).toString("base64")}\n`, + ); + + const backup = await host.backup({ repository: REPOSITORY, server: SERVER }); + assert.equal(backup.publicKey, publicKey); + assert.match(backup.recovery, /recovery\/backup-1$/); + assert.match(scripts[0].script, /umask 077/, "recovered key material is not world readable"); + assert.match(scripts[0].script, /deploy-key deploy-key\.pub known_hosts/); +}); + +test("candidate verification only reports ready on a real remote commit", async () => { + const remoteSha = "d".repeat(40); + const candidate = { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } }; + const context = { + repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, + profile: { branch: "main" }, + server: SERVER, + candidate, + }; + + const proven = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${remoteSha}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n`); + const proof = await proven.host.verifyCandidate(context); + assert.deepEqual(proof, { ready: true, remoteSha, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" }); + assert.match(proven.scripts[0].script, /git ls-remote --exit-code/); + assert.match(proven.scripts[0].script, /refs\/heads\/main/); + assert.equal(proven.scripts[0].options.timeout, 45_000); + assert.deepEqual(await proven.host.preflightCandidate(context), proof); + + const unproven = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n"); + assert.equal((await unproven.host.verifyCandidate(context)).ready, false); + await assert.rejects(() => unproven.host.preflightCandidate(context), /did not prove the remote branch/); +}); + +test("verifying the active key uses the repository-scoped paths rather than a candidate", async () => { + const { host, scripts } = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${"e".repeat(40)}\nfingerprint=SHA256:active\nhostFingerprint=SHA256:host\n`); + const paths = host.paths(REPOSITORY, SERVER); + + const proof = await host.verifyActive({ + repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, + profile: { branch: "main" }, + server: SERVER, + }); + assert.equal(proof.ready, true); + assert.ok(scripts[0].script.includes(paths.privateKey)); + assert.ok(scripts[0].script.includes(paths.knownHosts)); +}); + +test("promotion only replaces key material after proving the candidate is complete", async () => { + const { host, scripts } = keyHost(); + const paths = host.paths(REPOSITORY, SERVER); + const candidate = { paths: { directory: "/c", privateKey: "/c/deploy-key", publicKey: "/c/deploy-key.pub", knownHosts: "/c/known_hosts" } }; + + await host.promote({ repository: REPOSITORY, server: SERVER, candidate }); + const script = scripts[0].script; + assert.ok(script.includes("test -s '/c/deploy-key'"), "an empty candidate key is refused before anything is replaced"); + assert.ok(script.includes("test -s '/c/known_hosts'")); + assert.ok(script.indexOf("test -s") < script.indexOf("mv "), "the checks run before the swap"); + // The staging suffix is appended outside the quoted path, so the command reads + // mv ''.new '' rather than mv '.new' ''. + assert.ok(script.includes(`mv '${paths.privateKey}'.new '${paths.privateKey}'`), "the swap is atomic"); + assert.ok(script.includes(`cp -p '/c/deploy-key' '${paths.privateKey}'.new`), "the copy lands on the staging name first"); +}); + +test("rollback restores the recovery slot and removes the candidate", async () => { + const { host, scripts } = keyHost(); + const paths = host.paths(REPOSITORY, SERVER); + + await host.rollback({ + repository: REPOSITORY, + server: SERVER, + candidate: { paths: { directory: "/candidate" } }, + previous: { key: { recovery: "/recovery/backup-1" } }, + }); + assert.ok(scripts[0].script.includes("cp -p '/recovery/backup-1'")); + assert.ok(scripts[0].script.includes(paths.directory)); + assert.ok(scripts[0].script.includes("rm -rf -- '/candidate'")); +}); + +test("committing a rotation discards only the candidate directory", async () => { + const { host, scripts } = keyHost(); + await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } }); + // Every script carries the strict-mode preamble that bash() prepends. + assert.equal(scripts[0].script.split("\n").at(-1), "rm -rf -- '/candidate'"); + assert.ok(!scripts[0].script.includes(".forgeflow/git-credentials"), "the active key directory is never touched on commit"); +}); + +test("every server script runs under strict mode with Git prompts disabled", async () => { + const { host, scripts } = keyHost(); + await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } }); + assert.match(scripts[0].script, /^set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n/); + assert.equal(scripts[0].serverId, SERVER.id); +}); + +test("revocation moves key material aside so it can still be restored", async () => { + const { host, scripts } = keyHost(); + const paths = host.paths(REPOSITORY, SERVER); + + await host.revoke({ repository: REPOSITORY, server: SERVER }); + const script = scripts[0].script; + assert.ok(script.includes(`${paths.recovery}/revoked-`), "revoked material is kept in the recovery area"); + assert.match(script, /mv /, "the key is moved, never deleted"); + assert.ok(!/rm -rf/.test(script), "revocation must not destroy the recovery path"); +}); + +test("restore reinstates the newest recovery slot and reports the public evidence", async () => { + const publicKey = "ssh-ed25519 UkVT forgeflow"; + const { host, scripts } = keyHost( + `__FORGEFLOW_KEY_RESTORE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:restored\nhostFingerprint=SHA256:host\n`, + ); + + const restored = await host.restore({ repository: REPOSITORY, server: SERVER }); + assert.deepEqual(restored, { publicKey, fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" }); + assert.match(scripts[0].script, /sort \| tail -1/, "the newest slot is chosen deterministically"); + assert.ok(scripts[0].script.includes('test -n "$slot"'), "restoring without a recovery slot fails loudly"); +}); + +test("marker parsing keeps values that themselves contain separators", () => { + const parsed = parseDeployKeyMarker("noise\n__M__\nkey=a=b=c\nempty\nother=1\n", "__M__"); + assert.deepEqual(parsed, { key: "a=b=c", empty: "", other: "1" }); +}); diff --git a/tests/deployment-operations.test.mjs b/tests/deployment-operations.test.mjs new file mode 100644 index 0000000..feae762 --- /dev/null +++ b/tests/deployment-operations.test.mjs @@ -0,0 +1,497 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import deploymentModule from '../src/main/deployment-service.cjs'; + +const { DeploymentService } = deploymentModule; + +const SHA = 'a'.repeat(40); +const PREVIOUS_SHA = 'b'.repeat(40); + +async function serve(handler) { + const server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + url: `http://127.0.0.1:${server.address().port}/status`, + close: () => new Promise((resolve) => server.close(resolve)) + }; +} + +function jsonEndpoint(body, statusCode = 200) { + return serve((request, response) => { + response.writeHead(statusCode, { 'Content-Type': 'application/json' }); + response.end(typeof body === 'string' ? body : JSON.stringify(body)); + }); +} + +// A port nothing listens on, so the request fails instead of hanging. +async function unreachableUrl() { + const closed = await serve(() => {}); + await closed.close(); + return closed.url; +} + +function makeStore({ profile = null, operations = [] } = {}) { + const saved = new Map(operations.map((item) => [item.id, item])); + const states = new Map(); + return { + data: { operations, gitea: { baseUrl: 'https://gitea.example' } }, + getToken: () => 'gitea-secret-token', + getDeploymentProfile: () => profile, + getOperation: (id) => saved.get(id) || null, + addOperation: async (operation) => { + saved.set(operation.id, structuredClone(operation)); + return structuredClone(operation); + }, + saveDeploymentState: async (profileId, state) => { + states.set(profileId, state); + return state; + }, + saved, + states + }; +} + +function makeOperation(overrides = {}) { + return { + id: 'operation-1', + type: 'deployment', + action: 'deploy', + status: 'queued', + repository: 'jens/app', + profileId: 'production', + environment: 'production', + workflowFile: 'deploy.yml', + branch: 'main', + sha: SHA, + shortSha: SHA.slice(0, 7), + dispatchedAt: new Date().toISOString(), + stages: new DeploymentService({}, {}, {}).makeStages(), + logs: [], + ...overrides + }; +} + +function successPayload(overrides = {}) { + return { + repository: 'jens/app', + environment: 'production', + commit_sha: SHA, + previous_sha: PREVIOUS_SHA, + requested_sha: SHA, + request_id: 'operation-1', + last_exit_code: 0, + health: 'healthy', + ...overrides + }; +} + +test('the status endpoint reader accepts both key spellings and refuses anything that is not a commit SHA', async (context) => { + const service = new DeploymentService(makeStore(), {}, {}); + assert.deepEqual(await service.readStatusEndpoint(''), { configured: false }); + + const snake = await jsonEndpoint(successPayload()); + context.after(() => snake.close()); + const snakeResult = await service.readStatusEndpoint(snake.url); + assert.equal(snakeResult.ok, true); + assert.equal(snakeResult.liveSha, SHA); + assert.equal(snakeResult.previousSha, PREVIOUS_SHA); + assert.equal(snakeResult.requestedSha, SHA); + assert.equal(snakeResult.requestId, 'operation-1'); + assert.equal(snakeResult.lastExitCode, 0); + + const camel = await jsonEndpoint({ + repository: 'jens/app', + environment: 'PRODUCTION', + commitSha: SHA.toUpperCase(), + previousSha: PREVIOUS_SHA, + requestedSha: SHA, + requestId: 'operation-1', + lastExitCode: 3 + }); + context.after(() => camel.close()); + const camelResult = await service.readStatusEndpoint(camel.url); + assert.equal(camelResult.liveSha, SHA, 'a SHA is normalised to lower case'); + assert.equal(camelResult.environment, 'production', 'the environment is compared case-insensitively'); + assert.equal(camelResult.lastExitCode, 3); + + const untrusted = await jsonEndpoint({ commit_sha: 'HEAD', previous_sha: 'v1.2.3', request_id: 42, requested_sha: 'not-a-sha' }); + context.after(() => untrusted.close()); + const untrustedResult = await service.readStatusEndpoint(untrusted.url); + assert.equal(untrustedResult.liveSha, null); + assert.equal(untrustedResult.previousSha, null); + assert.equal(untrustedResult.requestedSha, null); + assert.equal(untrustedResult.requestId, null, 'a non-string request id is not accepted'); +}); + +test('an unreachable or failing status endpoint is reported instead of assumed healthy', async (context) => { + const service = new DeploymentService(makeStore(), {}, {}); + + const failing = await jsonEndpoint({ error: 'boom' }, 503); + context.after(() => failing.close()); + const failed = await service.readStatusEndpoint(failing.url); + assert.deepEqual( + { configured: failed.configured, reachable: failed.reachable, ok: failed.ok, status: failed.status }, + { configured: true, reachable: true, ok: false, status: 503 } + ); + + const offline = await service.readStatusEndpoint(await unreachableUrl()); + assert.equal(offline.reachable, false); + assert.equal(offline.ok, false); + assert.ok(offline.error); +}); + +test('healthchecks distinguish unconfigured, healthy, rejected and unreachable', async (context) => { + const service = new DeploymentService(makeStore(), {}, {}); + assert.deepEqual(await service.checkHealth(''), { configured: false, healthy: null }); + + const healthy = await jsonEndpoint({ ok: true }); + context.after(() => healthy.close()); + const healthyResult = await service.checkHealth(healthy.url); + assert.equal(healthyResult.healthy, true); + assert.equal(healthyResult.status, 200); + + const rejected = await jsonEndpoint({ ok: false }, 500); + context.after(() => rejected.close()); + assert.equal((await service.checkHealth(rejected.url)).healthy, false); + + const offline = await service.checkHealth(await unreachableUrl()); + assert.equal(offline.healthy, false); + assert.ok(offline.error); +}); + +test('profile state derives health from the status document when no healthcheck is configured', async (context) => { + const endpoint = await jsonEndpoint(successPayload({ health: 'degraded', deployed_at: '2026-08-01T10:00:00.000Z' })); + context.after(() => endpoint.close()); + const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' }; + const store = makeStore({ profile }); + const service = new DeploymentService(store, {}, {}); + + const state = await service.refreshProfileState('jens/app', 'production', { expectedSha: SHA }); + assert.equal(state.healthConfigured, false); + assert.equal(state.healthy, false, 'a degraded status document is not treated as healthy'); + assert.equal(state.liveSha, SHA); + assert.equal(state.versionMatches, true); + assert.equal(state.deployedAt, '2026-08-01T10:00:00.000Z'); + assert.equal(store.states.get('production').liveSha, SHA, 'the state is persisted'); +}); + +test('an unknown health word leaves the health state undecided rather than guessing', async (context) => { + const endpoint = await jsonEndpoint(successPayload({ health: 'starting' })); + context.after(() => endpoint.close()); + const store = makeStore({ profile: { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' } }); + const state = await new DeploymentService(store, {}, {}).refreshProfileState('jens/app', 'production'); + assert.equal(state.healthy, null); + assert.equal(state.versionMatches, null, 'without an expected SHA there is nothing to compare'); +}); + +test('refreshing the state of a removed profile fails loudly', async () => { + const service = new DeploymentService(makeStore({ profile: null }), {}, {}); + await assert.rejects(() => service.refreshProfileState('jens/app', 'gone'), /Deployment profile not found/); +}); + +test('a terminal operation is never polled again', async () => { + const operation = makeOperation({ status: 'success' }); + const store = makeStore({ operations: [operation] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => assert.fail('a finished deployment must not be polled'), + listWorkflowJobs: async () => assert.fail('a finished deployment must not be polled') + }, {}); + + assert.equal((await service.refreshOperation('operation-1')).status, 'success'); +}); + +test('an unknown operation is reported instead of silently ignored', async () => { + const service = new DeploymentService(makeStore(), {}, {}); + await assert.rejects(() => service.refreshOperation('missing'), /Deployment operation not found/); +}); + +test('a workflow run that is not visible yet keeps the deployment queued', async () => { + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => ({ run: null, source: 'actions' }) + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.status, 'queued'); + assert.equal(refreshed.stages.find((stage) => stage.id === 'queued').status, 'active'); + assert.match(refreshed.logs.at(-1), /queued or not visible/); +}); + +test('a failed runner marks the deployment failed and skips verification', async () => { + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => ({ run: { id: 7, runNumber: 7, status: 'completed', conclusion: 'failure', htmlUrl: 'https://gitea.example/run/7' }, source: 'actions' }), + listWorkflowJobs: async () => [{ name: 'build', status: 'completed', conclusion: 'failure' }] + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.status, 'failed'); + assert.equal(refreshed.failure.stage, 'runner'); + assert.equal(refreshed.stages.find((stage) => stage.id === 'healthcheck').status, 'skipped'); + assert.equal(refreshed.runUrl, 'https://gitea.example/run/7'); +}); + +test('a successful runner still fails when the server does not prove it runs the exact commit', async (context) => { + const endpoint = await jsonEndpoint(successPayload({ commit_sha: 'c'.repeat(40) })); + context.after(() => endpoint.close()); + const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' }; + const store = makeStore({ profile, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => ({ run: { id: 8, runNumber: 8, status: 'completed', conclusion: 'success' }, source: 'actions' }), + listWorkflowJobs: async () => [] + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.status, 'failed'); + assert.equal(refreshed.failure.stage, 'version-verification'); + assert.match(refreshed.failure.message, /instead of/); + assert.equal(refreshed.stages.find((stage) => stage.id === 'complete').status, 'failed'); +}); + +test('a verified deployment completes, and the same evidence marks a rollback as rolled back', async (context) => { + const endpoint = await jsonEndpoint(successPayload()); + context.after(() => endpoint.close()); + const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' }; + const gitea = { + findWorkflowRun: async () => ({ run: { id: 9, runNumber: 9, status: 'completed', conclusion: 'success' }, source: 'actions' }), + listWorkflowJobs: async () => [{ name: 'deploy', status: 'completed', conclusion: 'success' }] + }; + + const deployStore = makeStore({ profile, operations: [makeOperation()] }); + const deployed = await new DeploymentService(deployStore, gitea, {}).refreshOperation('operation-1'); + assert.equal(deployed.status, 'success'); + assert.equal(deployed.stages.find((stage) => stage.id === 'complete').status, 'complete'); + assert.equal(deployed.applicationState.liveSha, SHA); + assert.ok(deployed.logs.some((line) => line.includes('[job] deploy: success'))); + + const rollbackStore = makeStore({ profile, operations: [makeOperation({ action: 'rollback' })] }); + const rolledBack = await new DeploymentService(rollbackStore, gitea, {}).refreshOperation('operation-1'); + assert.equal(rolledBack.status, 'rolled-back'); +}); + +test('unavailable job details degrade to a warning instead of failing the refresh', async () => { + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => ({ run: { id: 10, runNumber: 10, status: 'in_progress', conclusion: null }, source: 'actions' }), + listWorkflowJobs: async () => { throw new Error('jobs API disabled'); } + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.status, 'running'); + assert.equal(refreshed.stages.find((stage) => stage.id === 'runner').status, 'active'); + assert.ok(refreshed.logs.some((line) => line.includes('Job details unavailable: jobs API disabled'))); +}); + +test('a failing poll is recorded on the operation without losing it', async () => { + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => { throw new Error('Gitea unreachable'); } + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.pollError, 'Gitea unreachable'); + assert.equal(refreshed.status, 'queued', 'the operation keeps its last known state'); + assert.ok(refreshed.logs.some((line) => line.includes('Status refresh failed'))); +}); + +test('a deployment whose profile was deleted reports that instead of crashing the poll', async () => { + const store = makeStore({ profile: null, operations: [makeOperation()] }); + const refreshed = await new DeploymentService(store, {}, {}).refreshOperation('operation-1'); + assert.match(refreshed.pollError, /profile used by this operation no longer exists/); +}); + +test('a refresh already in flight is not started a second time', async () => { + let calls = 0; + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => { calls += 1; await gate; return { run: null, source: 'actions' }; } + }, {}); + + const first = service.refreshOperation('operation-1'); + const second = await service.refreshOperation('operation-1'); + assert.equal(second.status, 'queued'); + release(); + await first; + assert.equal(calls, 1, 'the second caller reuses the in-flight refresh'); +}); + +test('job states drive the runner stage', () => { + const service = new DeploymentService(makeStore(), {}, {}); + const stageOf = (jobs) => { + const operation = makeOperation(); + service.mapJobsToStages(operation, jobs); + return operation.stages.find((stage) => stage.id === 'runner').status; + }; + + assert.equal(stageOf([{ status: 'in_progress' }]), 'active'); + assert.equal(stageOf([{ conclusion: 'success' }, { conclusion: 'failure' }]), 'failed'); + assert.equal(stageOf([{ conclusion: 'success' }]), 'complete'); + assert.equal(stageOf([{ status: 'waiting' }]), 'pending'); + + const untouched = makeOperation(); + service.mapJobsToStages(untouched, []); + assert.equal(untouched.stages.find((stage) => stage.id === 'queued').status, 'active', 'no jobs leaves the stages alone'); +}); + +test('a rejected dispatch records the failure on the operation and still surfaces the error', async () => { + const profile = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', + statusUrl: 'https://app.example.test/.well-known/forgeflow' + }; + const store = makeStore({ profile }); + const service = new DeploymentService(store, { + listWorkflowRuns: async () => ({ runs: [{ id: 1 }, { id: 2 }] }), + dispatchWorkflow: async () => { throw new Error('workflow file not found'); } + }, { + status: async () => ({ head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }), + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }, { info: async () => {}, error: async () => {} }); + + await assert.rejects( + () => service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', sha: SHA }), + /workflow file not found/ + ); + + const stored = [...store.saved.values()].at(-1); + assert.equal(stored.status, 'failed'); + assert.equal(stored.failure.stage, 'dispatch'); + assert.deepEqual(stored.baselineRunIds, ['1', '2'], 'runs that existed before dispatch are never mistaken for this one'); + assert.equal(stored.stages.find((stage) => stage.id === 'queued').status, 'failed'); +}); + +test('a rejected rollback dispatch is recorded the same way as a rejected deployment', async (context) => { + const endpoint = await jsonEndpoint(successPayload()); + context.after(() => endpoint.close()); + const profile = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: '' + }; + const store = makeStore({ profile }); + const service = new DeploymentService(store, { + listWorkflowRuns: async () => ({ runs: [] }), + dispatchWorkflow: async () => { throw new Error('rollback workflow is disabled'); } + }, { + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }, { info: async () => {}, error: async () => {} }); + + await assert.rejects( + () => service.rollback({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', targetSha: PREVIOUS_SHA }), + /rollback workflow is disabled/ + ); + + const stored = [...store.saved.values()].at(-1); + assert.equal(stored.action, 'rollback'); + assert.equal(stored.status, 'failed'); + assert.equal(stored.failure.stage, 'dispatch'); + assert.equal(stored.workflowFile, 'rollback.yml'); +}); + +test('rollback refuses every state where the target is not the server-reported previous version', async (context) => { + const endpoint = await jsonEndpoint(successPayload()); + context.after(() => endpoint.close()); + const base = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: '' + }; + const git = { verifyCommitOnRemoteBranch: async () => ({ valid: true }) }; + const repository = { fullName: 'jens/app', localPath: '/repo' }; + const rollback = (profile, targetSha) => new DeploymentService(makeStore({ profile }), {}, git) + .rollback({ repository, profileId: 'production', targetSha }); + + await assert.rejects(() => rollback({ ...base, rollbackWorkflowFile: '' }, PREVIOUS_SHA), /No rollback workflow is configured/); + await assert.rejects(() => rollback(base, 'c'.repeat(40)), /no longer the previous server version/); + await assert.rejects(() => rollback(base, SHA), /no longer the previous server version/, 'the live commit is not the previous one either'); + + // The "already live" guard only remains reachable when the server reports the + // same commit as both its live and its previous version. + const stuck = await jsonEndpoint(successPayload({ previous_sha: SHA })); + context.after(() => stuck.close()); + await assert.rejects(() => rollback({ ...base, statusUrl: stuck.url }, SHA), /already live/); + + const noPrevious = await jsonEndpoint(successPayload({ previous_sha: null })); + context.after(() => noPrevious.close()); + await assert.rejects(() => rollback({ ...base, statusUrl: noPrevious.url }, PREVIOUS_SHA), /does not report a previous version/); + + const otherEnvironment = await jsonEndpoint(successPayload({ environment: 'staging' })); + context.after(() => otherEnvironment.close()); + await assert.rejects(() => rollback({ ...base, statusUrl: otherEnvironment.url }, PREVIOUS_SHA), /does not match this repository and environment/); + + // An unreachable endpoint surfaces the underlying network error rather than a + // generic message, so the reason a rollback was refused stays diagnosable. + const unreachable = { ...base, statusUrl: await unreachableUrl() }; + await assert.rejects(() => rollback(unreachable, PREVIOUS_SHA), /fetch failed|ECONNREFUSED|must be reachable/i); +}); + +test('an unavailable run baseline degrades to a warning rather than blocking the dispatch', async () => { + const profile = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/.well-known/forgeflow' + }; + const store = makeStore({ profile }); + const service = new DeploymentService(store, { + listWorkflowRuns: async () => { throw new Error('Actions API disabled'); }, + dispatchWorkflow: async () => ({ accepted: true }) + }, { + status: async () => ({ head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }), + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }, { info: async () => {}, error: async () => {} }); + + const operation = await service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', sha: SHA }); + assert.equal(operation.status, 'queued'); + assert.deepEqual(operation.baselineRunIds, []); + assert.ok(operation.logs.some((line) => line.includes('Could not capture the pre-dispatch run baseline'))); +}); + +test('deployment logs never repeat a line and never carry the Gitea token', () => { + const service = new DeploymentService(makeStore(), {}, {}); + const operation = makeOperation({ logs: undefined }); + + service.appendLog(operation, 'plain line'); + service.appendLog(operation, 'plain line'); + service.appendLog(operation, 'authorization: token gitea-secret-token'); + assert.equal(operation.logs.length, 2, 'a repeated line is not appended twice'); + assert.ok(!operation.logs.at(-1).includes('gitea-secret-token')); + + for (let index = 0; index < 1200; index += 1) service.appendLog(operation, `line ${index}`); + assert.equal(operation.logs.length, 1000, 'the log is bounded'); + assert.equal(operation.logs.at(-1), 'line 1199'); +}); + +test('a repository identity that is not exactly owner/repo is refused', () => { + const service = new DeploymentService(makeStore(), {}, {}); + assert.deepEqual(service.splitRepository('jens/app'), { owner: 'jens', repo: 'app' }); + for (const value of ['', 'app', 'jens/app/extra', '/app', 'jens/']) { + assert.throws(() => service.splitRepository(value), /Invalid Gitea repository identity/); + } +}); + +test('deployment is refused without a linked local repository', async () => { + const service = new DeploymentService(makeStore(), {}, {}); + await assert.rejects(() => service.deploy({ repository: { fullName: 'jens/app' }, profileId: 'production', sha: SHA }), /linked local repository/); + await assert.rejects(() => service.rollback({ repository: { localPath: '/repo' }, profileId: 'production', targetSha: SHA }), /linked local repository/); +}); + +test('validation refuses every local state that would deploy something other than the reviewed commit', async () => { + const profile = { id: 'production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/status' }; + const base = { head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }; + const cases = [ + [{ ...base, head: 'c'.repeat(40) }, /no longer matches the local repository/], + [{ ...base, branch: { ...base.branch, head: 'feature' } }, /only allows deployments from main/], + [{ ...base, counts: { changed: 2 } }, /Commit local changes/], + [{ ...base, branch: { ...base.branch, ahead: 1 } }, /Push all local commits/], + [{ ...base, branch: { ...base.branch, behind: 1 } }, /Synchronize with Gitea/], + [{ ...base, branch: { ...base.branch, upstream: '' } }, /Publish this branch/] + ]; + + for (const [status, expected] of cases) { + const service = new DeploymentService(makeStore({ profile }), {}, { + status: async () => status, + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }); + await assert.rejects(() => service.validateDeploy({ localPath: '/repo' }, profile, SHA), expected); + } +});