From 18f42621c258576da3635866902f0e4dc4da013a Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:54:51 +0200 Subject: [PATCH] feat: harden release signing and coverage gate --- SOURCE_MANIFEST.txt | 44 ++++--- docs/COVERAGE_POLICY.md | 20 ++- docs/PRODUCTION_READINESS_1.0.md | 5 +- docs/RELEASING.md | 50 ++++++- docs/TEST_MATRIX.md | 6 +- package.json | 3 +- scripts/signed-electron-builder-config.cjs | 22 ++++ scripts/validate-signing-environment.mjs | 17 +++ src/main/config-store.cjs | 8 +- tests/config-store.test.mjs | 88 +++++++++++++ tests/preflight.test.mjs | 112 ++++++++++++++++ tests/server-inventory-branches.test.mjs | 144 +++++++++++++++++++++ tests/update-service.test.mjs | 16 +++ 13 files changed, 496 insertions(+), 39 deletions(-) create mode 100644 scripts/signed-electron-builder-config.cjs create mode 100644 scripts/validate-signing-environment.mjs create mode 100644 tests/server-inventory-branches.test.mjs diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 40774d8..af6af69 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -2,8 +2,13 @@ ForgeFlow 0.10.0 source manifest SHA-256 BYTES PATH (The manifest excludes itself, dependencies and generated release artifacts.) cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml -0182dfbbf5b9fc5e62f064f094f5412bf245f9a67ec712e8807f2ce4da443717 88 .gitignore +4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc +d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml +528fe408ad4b49c621dd57dd831cecf7ec00b8865069f6ed3b80fefc2e0b7823 8267 .playwright-mcp/page-2026-07-29T17-41-26-269Z.yml +804c6dac953c5094671784919ff35ebda756306a04ef34fe01cd7cf7cd50b2dc 16909 .playwright-mcp/page-2026-07-29T17-41-46-590Z.yml +0f17fdea98e15ebcf7f3ed356d31b0fc89be62f7bc1d25b26c8a41e4b5deca68 160 .playwright-mcp/page-2026-07-29T17-47-10-112Z.yml +5f348bcf74baa845958884bd2258e1ce4121d386c945777b0e80912602e5b5f6 17227 .playwright-mcp/page-2026-07-29T17-47-19-366Z.yml 89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 build-windows.ps1 0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png 09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png @@ -17,6 +22,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md +e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md 72e846f591c47a0291e7466e58e052d3d5afcf551c4e6c848632ac3c552a1244 3043 docs/CURRENT_STATE.md 8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md @@ -24,6 +30,7 @@ eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 1d8aa3c688a9d330b9a5303b09e9e3049c50bf04f97807c7ff659607baa34c32 1464 docs/ERROR_CODES.md a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md 648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md +0baec6f5152b332589bcdc589c2f5a32a8e3e29afd4cfe125403b77eb6c78095 3716 docs/PRODUCTION_READINESS_1.0.md f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md 979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md @@ -60,7 +67,7 @@ ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720 25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md 720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md -ed9af435be5986cc597f75d8de89cd51dd4cc8860c8fcbb4cd8eafdd4438857a 1364 docs/RELEASING.md +beea33f1dcaf21ef2eda1cc0fbd48cb26811d5eacdb9facf11a9153845fe2fbb 4106 docs/RELEASING.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png @@ -74,7 +81,7 @@ c8a5e80bb9fd2d442d2d23d30e6ac1528cf2330e6e19492b7c6799e2d1508b53 112868 2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md -bcfef2c5a180e3665df3c08517d5251339682ca5bcc90e4681b5bbf4f9c7ba55 6296 docs/TEST_MATRIX.md +4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md 4bffda594058697345569d937d7a524f094ac85a0f338f0ef18fcf3f94d8c299 1292 eslint.config.js c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml @@ -88,15 +95,15 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md bf3f507e676b3fd834240d72b945be4cd3b4018e5ebe570594eb27b8047c3969 179806 package-lock.json -e0b7a2add01750396a149830204bf2f50ddf86c6612fe08e2dd984f5a777cbfb 5097 package.json +8debf4523a801760c7943d5a3caea59d54add47d84c95cacac6b9fe80f8493b8 5606 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 -c2054a7d246423270c1bb2a73c1406fcb37de132b515780bcf51fa755c30641b 14329 reports/architecture-audit.json -121f3f78eee25b8a896fdad8d2adb85e9be5a1469b4510cd481d1ca8a4e2c106 1114 reports/architecture-audit.md +3b2572a4d3a8aa3101bae6af49617e10062c43d72430314e1a31b04bc2933902 14329 reports/architecture-audit.json +285dace9a76c800cca8d1222c9322690575c0a92c2a0d507d1b9910b02864b7c 1114 reports/architecture-audit.md 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1 f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 @@ -107,13 +114,16 @@ fca922d7d1de598a0153f3828300c7d0327e0189c06a53a23d9fc33b36d45d91 4741 842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs +b547dcb3c32f1c63185c13b899730cf2d7cde6c6e439a2ae60a58b740bb33f6f 819 scripts/signed-electron-builder-config.cjs +c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1 4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs -d2dd98055e50f11b4e1484531e43fb5ac7f876bea4b9cf5bca2cb0a15022b60a 1913 scripts/verify-release-signatures.mjs +aad97c5452d35ad5f49c67124fda012ab4f89b778d0f3e9e3f553d2977ef81d0 1565 scripts/validate-signing-environment.mjs +dd1d59fde63ac1450d26c837adfb4b0ff760ef2e2817bc50fcbf40b1f404409a 2089 scripts/verify-release-signatures.mjs e8c5d00737d4c5e2c37ff683e569e8d7a0510be233db3840460c78be69f2c4bc 17163 scripts/verify.mjs 0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs -04b97b9b02ad8746d2229d40ba0585088118ecd18e1b5f3a1911a4f3b63831d6 32271 src/main/config-store.cjs +92856d698d0a5cc0a3e112e9dc05eb6de473809e9f82e7b08dd21f13f4ec1af8 32309 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 @@ -166,7 +176,7 @@ a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 4b0a64610da0c446f15a43e4753b26f29de72e07793e2fa7b7322d4f07cf8050 27806 src/renderer/mock-deployment-bridge.js 26065ffa2359cd27b9c9b5b9fb67bcad83ba0f118960c7c024e7e9392dbb16a3 20032 src/renderer/mock-repository-bridge.js 94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js -45692591428575b518678a6b548c25d3de95f568541e5648b0f06f42c48b5bbf 77872 src/renderer/styles.css +abe196f5ecdd73e7b6ca67a41c90e55bfc507e084f786227264cc780b1ce83a3 78001 src/renderer/styles.css 1703e64533b7e2717b27c5776296c7dd76331e6f97e8005aea9fd688f1aee3ae 94834 src/renderer/views.js 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs @@ -183,10 +193,11 @@ f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs 33bc892963e89b868235b959498308a456b1285057bda524ba7c1d5a9ee2159e 8763 tests/browser/forgeflow.spec.mjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs +ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs 1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs -bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs +50e90cd41dae952a14903c40c0cb1fd191d7b875cfeb730f06a454e755fad7ce 9076 tests/deployment-status.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs @@ -194,24 +205,25 @@ e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 c00bbd8eae5cef7856c8283d6b40dedb81083bf57ad762e89ab79e0f312da351 3271 tests/git-validator-policy.test.mjs 73d00729696e5067ba33dd6d43b018d89ce7fdd561a60ab66648d3283fb54d21 5370 tests/git-validator.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs -771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs +ca2c2c47b2532a74c7a77b9473ff417e0a34f0dbd8801936fd0e301a38f06a9a 18128 tests/gitea-actions.test.mjs 8f260f35aaf162999ddcd0f851a4f215222d9de0880d602b8322facdaa4c2cb0 9190 tests/inventory-classifier.test.mjs 9643622a03ea0a88fb7d72ce43e469ff4f814902f4b3d2a672990637d66ef075 2009 tests/ipc-contract.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs -c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs +1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 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 -75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs +ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs +e631e9ca49a5bac7075860aac2ff4d377a32a78377b70e06ecf833f0f192fd5f 11552 tests/server-inventory-branches.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs -2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs +0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs -fb5213c5f8ae8e98deed620eb93eb27a2317ef5a1ea671dd5ea548f0fd072362 44283 tests/unraid-deployment.test.mjs -861bad3f118c89bd17acf4373170c208c6e29c89af1d40fb2cf010f587a5016f 19008 tests/update-service.test.mjs +4182b61e395aff310b9a964c973a43c3566df0b44c454054c3abdd9459e86e3b 49134 tests/unraid-deployment.test.mjs +603c305301eaf0955574b6eb8b393140a3d3f0eabcee558b817130e2191c72bf 19880 tests/update-service.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs 8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md diff --git a/docs/COVERAGE_POLICY.md b/docs/COVERAGE_POLICY.md index 5cec910..6df173c 100644 --- a/docs/COVERAGE_POLICY.md +++ b/docs/COVERAGE_POLICY.md @@ -1,20 +1,18 @@ # Coverage policy ForgeFlow treats coverage as release evidence, not as a target to game. `npm run coverage` -enforces 75% statements, 75% lines, 75% functions and 60% branches globally. +enforces 75% statements, 75% lines, 75% functions and 65% branches globally. The July 2026 hardening pass raised the measured baseline from 69.74% statements/lines, -68.82% functions and 55.38% branches to at least 78% statements/lines, 79% functions and -60% branches. The requested 65% global branch target was investigated but is not used as -the release gate yet. Node/V8 discovers additional branch counters when previously -unexecuted functions become covered; the denominator grew from 2,537 to more than 3,100 -while the new tests added hundreds of asserted branches. Raising the number by excluding -command builders, platform guards or error adapters would make the result look better -without increasing deployment safety. +68.82% functions and 55.38% branches to 81.48% statements/lines, 82.07% functions and +65.59% branches. Node/V8 discovered additional branch counters when previously unexecuted +functions became covered; the denominator grew from 2,537 to 3,473 while the new tests +added hundreds of asserted decisions. No command builders, platform guards or error +adapters were excluded to improve the result cosmetically. -The 60% global gate is therefore paired with scenario-level evidence for the critical +The 65% global gate is paired with scenario-level evidence for the critical boundaries: deploy-key rollback, deployment verification, Gitea authentication and redirects, SSH host identity and output limits, inventory reconciliation, stale plans, configuration recovery, release integrity and updater failure modes. New code must not -reduce the global baseline. A future increase to 65% should come from additional asserted -failure scenarios, not ignore comments or source exclusions. +reduce the global baseline. Future increases must come from additional asserted failure +scenarios, not ignore comments or source exclusions. diff --git a/docs/PRODUCTION_READINESS_1.0.md b/docs/PRODUCTION_READINESS_1.0.md index 083b3ef..1813bf3 100644 --- a/docs/PRODUCTION_READINESS_1.0.md +++ b/docs/PRODUCTION_READINESS_1.0.md @@ -45,9 +45,8 @@ console events, DOM, fixture details and test identity. ## 7. Coverage and dependencies Coverage increased from 69.74% statements/lines, 68.82% functions and 55.38% -branches to at least 78.75%, 79.68% and 59.25%, respectively, before the last -ConfigStore tests. The enforced gates are 75/75/75/60; the rationale and 65% -follow-up are in `COVERAGE_POLICY.md`. Production dependencies have zero known +branches to 81.48%, 82.07% and 65.59%, respectively. The enforced gates are now +75/75/75/65 and are documented in `COVERAGE_POLICY.md`. Production dependencies have zero known audit vulnerabilities. Remaining development findings belong to current upstream ESLint/electron-builder toolchains and are assessed in `DEPENDENCY_AUDIT.md`. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 500edc4..25e590d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -18,9 +18,57 @@ certificate through its supported CSC environment variables, then set: ```powershell $env:FORGEFLOW_SIGNED_RELEASE = '1' $env:FORGEFLOW_EXPECTED_PUBLISHER = 'CN=Exact Legal Publisher, O=Exact Legal Organization, C=BE' -npm run dist:win +npm run dist:win:signed ``` +The signed command requires signed-release mode, an exact publisher subject and +either a classic `WIN_CSC_LINK` certificate configuration or complete Azure +credentials. It enables electron-builder's `forceCodeSigning` gate, so missing +signing material cannot silently produce a production candidate. + +### Recommended: Azure Artifact Signing + +1. Create an Azure Artifact Signing account and identity-validation certificate + profile for the legal ForgeFlow publisher. +2. Create an Entra app registration and give its service principal the + `Artifact Signing Certificate Profile Signer` role on that account. +3. Store the following as protected CI variables—never in Git: + +```powershell +$env:AZURE_TENANT_ID = '' +$env:AZURE_CLIENT_ID = '' +$env:AZURE_CLIENT_SECRET = '' +$env:FORGEFLOW_AZURE_SIGNING_ENDPOINT = 'https://.codesigning.azure.net/' +$env:FORGEFLOW_AZURE_SIGNING_ACCOUNT = '' +$env:FORGEFLOW_AZURE_CERTIFICATE_PROFILE = '' +$env:FORGEFLOW_SIGNED_RELEASE = '1' +$env:FORGEFLOW_EXPECTED_PUBLISHER = 'CN=' +npm run dist:win:signed +``` + +The generated configuration uses SHA-256 and Microsoft's RFC 3161 timestamp +service. `FORGEFLOW_EXPECTED_PUBLISHER` must still contain the complete subject +reported by the resulting certificate, even though Azure's builder option uses +its CN component. + +### Alternative: classic CA certificate + +When a CA supplies a CI-compatible PFX or hardware/cloud connector supported by +electron-builder, configure its protected values and use the same command: + +```powershell +$env:WIN_CSC_LINK = 'C:\secure\forgeflow-signing.pfx' +$env:WIN_CSC_KEY_PASSWORD = '' +$env:FORGEFLOW_SIGNED_RELEASE = '1' +$env:FORGEFLOW_EXPECTED_PUBLISHER = 'CN=, O=, C=BE' +npm run dist:win:signed +``` + +Do not purchase a certificate before the CA or cloud service confirms the exact +legal subject and that its key-storage method works with the intended Windows CI +runner. An ordinary OV certificate can still accumulate SmartScreen reputation; +EV or Azure Artifact Signing provides immediate publisher trust. + Both installer and portable executable must have a valid Authenticode signature, the expected publisher and a timestamp. The build also creates SHA-256 files, a CycloneDX SBOM and a provenance document containing commit and build ID. diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 59365bc..12e5e6c 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -5,9 +5,9 @@ The quality chain contains more than 230 Node and browser acceptance cases. The latest Windows source run completed without failures and retains one explicitly Bash-dependent skip. `npm run coverage` enforces 75% lines/statements/functions -and 60% branches; the measured hardening baseline is 78.75% statements/lines, -79.68% functions and 59.25% branches before the final ConfigStore additions. -See `COVERAGE_POLICY.md` for the non-gamed branch policy. +and 65% branches; the measured hardening baseline is 81.48% statements/lines, +82.07% functions and 65.59% branches. See `COVERAGE_POLICY.md` for the +non-gamed branch policy. `npm run quality` is the local equivalent of `.gitea/workflows/quality.yml` and runs source verification, ESLint, the complete suite and coverage on Node 22 LTS. diff --git a/package.json b/package.json index d3d72fe..93a9076 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,10 @@ "demo": "node scripts/serve-demo.mjs", "test": "node --test tests/*.test.mjs", "lint": "eslint .", - "coverage": "c8 --check-coverage --lines 75 --functions 75 --branches 60 --statements 75 node --test tests/*.test.mjs", + "coverage": "c8 --check-coverage --lines 75 --functions 75 --branches 65 --statements 75 node --test tests/*.test.mjs", "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:win:signed": "node scripts/validate-signing-environment.mjs && electron-builder --config scripts/signed-electron-builder-config.cjs --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", "dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs", "doctor": "node scripts/doctor.mjs", diff --git a/scripts/signed-electron-builder-config.cjs b/scripts/signed-electron-builder-config.cjs new file mode 100644 index 0000000..f1adc2b --- /dev/null +++ b/scripts/signed-electron-builder-config.cjs @@ -0,0 +1,22 @@ +"use strict"; + +const pkg = require("../package.json"); + +const expectedPublisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim(); +const commonName = expectedPublisher.match(/^CN=([^,]+)/i)?.[1]?.trim(); +const useAzure = Boolean(String(process.env.FORGEFLOW_AZURE_SIGNING_ENDPOINT || "").trim()); +const win = { ...pkg.build.win, forceCodeSigning: true }; + +if (useAzure) { + win.azureSignOptions = { + publisherName: commonName, + endpoint: process.env.FORGEFLOW_AZURE_SIGNING_ENDPOINT, + codeSigningAccountName: process.env.FORGEFLOW_AZURE_SIGNING_ACCOUNT, + certificateProfileName: process.env.FORGEFLOW_AZURE_CERTIFICATE_PROFILE, + fileDigest: "SHA256", + timestampDigest: "SHA256", + timestampRfc3161: "http://timestamp.acs.microsoft.com", + }; +} + +module.exports = { ...pkg.build, win }; diff --git a/scripts/validate-signing-environment.mjs b/scripts/validate-signing-environment.mjs new file mode 100644 index 0000000..24be4c2 --- /dev/null +++ b/scripts/validate-signing-environment.mjs @@ -0,0 +1,17 @@ +const signedRelease = process.env.FORGEFLOW_SIGNED_RELEASE === "1"; +const publisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim(); +const classicCertificate = String(process.env.WIN_CSC_LINK || process.env.CSC_LINK || "").trim(); +const azureNames = ["AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", "FORGEFLOW_AZURE_SIGNING_ENDPOINT", "FORGEFLOW_AZURE_SIGNING_ACCOUNT", "FORGEFLOW_AZURE_CERTIFICATE_PROFILE"]; +const azureValues = azureNames.map((name) => String(process.env[name] || "").trim()); +const azure = azureValues.every(Boolean); +const partialAzure = azureValues.some(Boolean) && !azure; + +if (!signedRelease) throw new Error("FORGEFLOW_SIGNED_RELEASE=1 is required for the production signing build."); +if (!/^CN=.+/i.test(publisher)) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER must be the exact certificate subject beginning with CN=."); +if (partialAzure) throw new Error(`Azure Artifact Signing is incomplete. Configure: ${azureNames.filter((_, index) => !azureValues[index]).join(", ")}.`); +if (!classicCertificate && !azure) throw new Error("Configure WIN_CSC_LINK/CSC_LINK or all Azure Artifact Signing credentials before building a signed release."); +if (classicCertificate && !String(process.env.WIN_CSC_KEY_PASSWORD || process.env.CSC_KEY_PASSWORD || "").trim()) { + throw new Error("WIN_CSC_KEY_PASSWORD or CSC_KEY_PASSWORD is required for classic certificate signing."); +} + +console.log(`Production ${azure ? "Azure Artifact Signing" : "classic certificate"} environment accepted for exact publisher ${publisher}.`); diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index f0942c9..ce38ee2 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -202,7 +202,7 @@ class ConfigStore { return { persistent: true, preserved: false }; } - if (safeStorage.isEncryptionAvailable()) { + if (safeStorage?.isEncryptionAvailable?.()) { this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64'); this.sessionToken = null; return { persistent: true, preserved: false }; @@ -217,7 +217,7 @@ class ConfigStore { if (this.sessionToken) return this.sessionToken; if (!this.data.gitea.encryptedToken) return ''; try { - return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64')); + return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || ''; } catch { return ''; } @@ -227,7 +227,7 @@ class ConfigStore { encryptSecret(value) { const text = String(value || ''); if (!text) return null; - if (!safeStorage.isEncryptionAvailable()) { + if (!safeStorage?.isEncryptionAvailable?.()) { const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.'); error.code = 'SECURE_STORAGE_UNAVAILABLE'; throw error; @@ -237,7 +237,7 @@ class ConfigStore { decryptSecret(value) { if (!value) return ''; - try { return safeStorage.decryptString(Buffer.from(value, 'base64')); } + try { return safeStorage?.decryptString?.(Buffer.from(value, 'base64')) || ''; } catch { return ''; } } diff --git a/tests/config-store.test.mjs b/tests/config-store.test.mjs index 09fb983..2bbe194 100644 --- a/tests/config-store.test.mjs +++ b/tests/config-store.test.mjs @@ -165,3 +165,91 @@ test("profile, review and operation lookups return safe empty values", async (t) await store.deleteInventoryReviewDecision("missing", "workload"); await store.deleteDeploymentProfile("missing/repo", "profile"); }); + +test("session credentials preserve, replace and clear safely when OS encryption is unavailable", async (t) => { + const { store } = await storeFixture(t); + assert.deepEqual(store.setToken(" session-token "), { persistent: false, preserved: false }); + assert.equal(store.getToken(), "session-token"); + assert.deepEqual(store.setToken("", { preserveExisting: true }), { persistent: false, preserved: true }); + assert.equal(store.getToken(), "session-token"); + assert.deepEqual(store.setToken("replacement"), { persistent: false, preserved: false }); + assert.equal(store.getToken(), "replacement"); + assert.deepEqual(store.setToken(""), { persistent: true, preserved: false }); + assert.equal(store.getToken(), ""); + assert.throws(() => store.encryptSecret("password"), (error) => error.code === "SECURE_STORAGE_UNAVAILABLE"); + assert.equal(store.encryptSecret(""), null); + assert.equal(store.decryptSecret(null), ""); + assert.equal(store.decryptSecret("not-base64-encrypted-data"), ""); +}); + +test("setup, Gitea updates and generic patches retain normalized public state", async (t) => { + const { store } = await storeFixture(t); + const completed = await store.completeSetup({ + baseUrl: "https://gitea.test", token: "token", user: { login: "jens" }, + workspaceRoots: [" C:/Projects ", "C:/Projects", ""] + }); + assert.equal(completed.state.setupComplete, true); + assert.equal(completed.state.gitea.hasToken, true); + assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]); + const update = await store.updateGitea({ baseUrl: "https://new.test", token: "", user: null }); + assert.equal(update.preserved, true); + assert.equal(store.data.gitea.user.login, "jens"); + const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] }); + assert.equal(patched.appearance, "light"); + assert.deepEqual(patched.workspaceRoots, ["D:/Code"]); + assert.equal("encryptedToken" in patched.gitea, false); +}); + +test("server saves reject absent credentials before mutating configuration", async (t) => { + const { store } = await storeFixture(t); + await assert.rejects( + store.saveServer({ host: "unraid", username: "root", authType: "password", basePath: "/mnt/apps" }), + /password is required/i + ); + await assert.rejects( + store.saveServer({ host: "unraid", username: "root", authType: "privateKey", basePath: "/mnt/apps", privateKeyPath: "" }), + /select a private key/i + ); + assert.deepEqual(store.data.servers, []); +}); + +test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => { + const { store } = await storeFixture(t); + const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" }); + assert.equal(actions.provider, "gitea-actions"); + assert.equal(actions.name, "qa"); + assert.equal(actions.branch, "main"); + assert.equal(actions.workflowFile, "deploy.yml"); + assert.equal(actions.rollbackWorkflowFile, ""); + assert.equal(actions.confirmationRequired, true); + + const unraid = store.normalizeDeploymentProfile({ + id: "all-options", name: " Server ", environment: "production", provider: "ssh-unraid", branch: "release", + serverId: " server ", remoteFolder: "apps/App", deploymentMode: "monitor-only", generatedCompose: true, + composeFiles: [], composeServices: ["WEB", "Worker"], composeProject: "App.prod", composeWorkingDir: "/mnt/apps/App", + containerName: "Visible.App", cloneUrl: "https://gitea.test/Owner/App.git", alignRemote: true, + hostPort: -2, containerPort: 70000, webUiUrl: "http://[IP]:[PORT:3000]/", iconMode: "upload", + iconFilePath: "C:/icon.png", dockerShell: "/bin/bash", preservePaths: [], adoptedFromServer: true, + serverSourceOfTruth: true, manageDockerMan: true, forceRecreate: true, removeOrphans: true, + workloadIdentity: { workloadId: "one" }, serverGitAccess: { configured: true, deployKeyId: "invalid", keyFingerprint: "", hostFingerprint: "", configuredAt: "now" }, + provenance: { remoteFolder: "server" }, detectedMetadata: { source: "docker" }, serverIconReference: " icon ", + deploymentPolicy: { frozen: true, freezeReason: " maintenance ", requireNote: true, maintenanceWindows: [{ days: [0, 0, 6, 7, "bad"], start: "01:00", end: "02:00" }] } + }); + assert.equal(unraid.name, "Server"); + assert.equal(unraid.serverId, "server"); + assert.equal(unraid.composeFile, "docker-compose.yml"); + assert.deepEqual(unraid.composeServices, ["web", "worker"]); + assert.equal(unraid.hostPort, 1); + assert.equal(unraid.containerPort, 65535); + assert.equal(unraid.iconMode, "upload"); + assert.equal(unraid.dockerShell, "/bin/bash"); + assert.equal(unraid.serverGitAccess.deployKeyId, null); + assert.equal(unraid.serverGitAccess.keyFingerprint, null); + assert.deepEqual(unraid.deploymentPolicy.maintenanceWindows[0].days, [0, 6]); + assert.equal(unraid.serverIconReference, "icon"); + + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeService: "app", composeServices: ["bad service"] }), /Compose services/); + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeProject: "bad project!" }), /Compose project/); + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeWorkingDir: "relative" }), /working directory/); + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", containerName: "bad name" }), /Container name/); +}); diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs index 5129041..f31af66 100644 --- a/tests/preflight.test.mjs +++ b/tests/preflight.test.mjs @@ -63,3 +63,115 @@ test('deployment preflight verifies exact Git, workflow, Actions and server prer assert.equal(result.checks.filter((item) => item.status === 'fail').length, 0); assert.equal(result.head, sha); }); + +test('system preflight reports unavailable Git, storage, roots and rejected Gitea credentials', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-failures-')); + t.after(() => rm(root, { recursive: true, force: true })); + const ordinaryFile = path.join(root, 'not-a-directory'); + await writeFile(ordinaryFile, 'file'); + const events = []; + const service = new PreflightService({ + store: { data: { gitea: { baseUrl: 'https://stored.test' } }, getToken: () => 'stored-token' }, + git: { isAvailable: async () => ({ available: false, error: 'git missing' }) }, + gitea: { validateConnection: async () => { throw new Error('token rejected'); } }, + deployments: {}, diagnostics: { logDirectory: path.join(root, 'logs'), info: async (...args) => events.push(args) }, + userDataPath: path.join(root, 'data'), secureStorageAvailable: () => false + }); + service.writableDirectory = async (directory) => { + if (directory.endsWith('data')) throw new Error('read only'); + return true; + }; + const result = await service.runSystem({ roots: [ordinaryFile, path.join(root, 'missing'), ordinaryFile, ''] }); + assert.equal(result.checks.find((item) => item.id === 'git.available').status, 'fail'); + assert.equal(result.checks.find((item) => item.id === 'storage.userdata').status, 'fail'); + assert.equal(result.checks.find((item) => item.id === 'storage.diagnostics').status, 'pass'); + assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'warning'); + assert.equal(result.checks.find((item) => item.id === 'workspace.root.0').status, 'fail'); + assert.equal(result.checks.find((item) => item.id === 'workspace.root.1').status, 'fail'); + assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'fail'); + assert.equal(result.summary.ready, false); + assert.equal(events[0][0], 'preflight.system.completed'); +}); + +test('system preflight warns on incomplete Git identity and accepts unknown Gitea version', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-identity-')); + t.after(() => rm(root, { recursive: true, force: true })); + const service = new PreflightService({ + store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' }, + git: { isAvailable: async () => ({ available: true, version: 'git' }) }, + gitea: { validateConnection: async () => ({ version: null, user: null, repositoryCount: 0 }) }, deployments: {}, + diagnostics: { logDirectory: path.join(root, 'logs'), info: async () => {} }, userDataPath: path.join(root, 'data') + }); + service.gitIdentity = async () => ({ name: '', email: '' }); + const result = await service.runSystem({ baseUrl: 'https://gitea.test', token: 'token', roots: [] }); + assert.equal(result.checks.find((item) => item.id === 'git.identity').status, 'warning'); + assert.match(result.checks.find((item) => item.id === 'gitea.connection').detail, /unknown version.*user/i); + assert.equal(result.checks.find((item) => item.id === 'gitea.repositories').status, 'pass'); + assert.equal(result.checks.find((item) => item.id === 'workspace.roots').status, 'warning'); + + service.gitIdentity = async () => { throw new Error('identity lookup failed'); }; + const second = await service.runSystem(); + assert.match(second.checks.find((item) => item.id === 'git.identity').detail, /lookup failed/i); +}); + +test('deployment preflight fails fast for invalid identity, profile and missing local link', async () => { + const diagnostics = []; + const service = new PreflightService({ + store: { getDeploymentProfile: (_name, id) => id === 'known' ? { id: 'known', name: 'Production' } : null }, + git: {}, gitea: {}, deployments: {}, diagnostics: { info: async (...args) => diagnostics.push(args) }, userDataPath: '' + }); + await assert.rejects(service.runDeployment({ repository: null, profileId: 'known' }), /identity is required/i); + await assert.rejects(service.runDeployment({ repository: { fullName: 'owner/app' }, profileId: 'missing' }), /profile not found/i); + const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: '' }, profileId: 'known' }); + assert.deepEqual(result.summary.blocking, ['repository.linked']); + assert.equal(diagnostics[0][0], 'preflight.deployment.completed'); +}); + +test('deployment preflight preserves actionable evidence across Git, workflow and endpoint failures', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-degraded-')); + t.after(() => rm(root, { recursive: true, force: true })); + const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml' }; + const service = new PreflightService({ + store: { getDeploymentProfile: () => profile }, + git: { + status: async () => ({ root, head: 'b'.repeat(40), clean: false, counts: { changed: 4 }, branch: { head: '', upstream: '', ahead: 2, behind: 3 } }), + verifyCommitOnRemoteBranch: async () => { throw new Error('commit not published'); } + }, + gitea: { repositoryFileExists: async () => false, listWorkflowRuns: async () => { throw new Error('Actions disabled'); } }, + deployments: {}, diagnostics: { info: async () => {} }, userDataPath: root + }); + const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id }); + for (const id of ['git.branch', 'git.clean', 'git.upstream', 'git.sync', 'git.remote-sha', 'workflow.deploy.local', 'workflow.deploy.remote', 'gitea.actions', 'server.status.configured']) { + assert.equal(result.checks.find((item) => item.id === id).status, 'fail', id); + } + assert.equal(result.checks.find((item) => item.id === 'workflow.rollback.local').status, 'warning'); + assert.equal(result.checks.find((item) => item.id === 'server.health').status, 'warning'); + assert.equal(result.head, 'b'.repeat(40)); +}); + +test('deployment preflight distinguishes unreachable and mismatched status evidence', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-status-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, '.gitea', 'workflows'), { recursive: true }); + await writeFile(path.join(root, '.gitea', 'workflows', 'deploy.yml'), 'name: deploy\n'); + const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app/status', healthcheckUrl: 'https://app/health' }; + let status = { reachable: false, ok: false, status: 503, error: '' }; + const service = new PreflightService({ + store: { getDeploymentProfile: () => profile }, + git: { status: async () => { throw new Error('checkout corrupt'); } }, + gitea: { repositoryFileExists: async () => { throw new Error('Gitea offline'); } }, + deployments: { readStatusEndpoint: async () => status, checkHealth: async () => ({ healthy: false, status: 500, error: '' }) }, + diagnostics: { info: async () => {} }, userDataPath: root + }); + const unreachable = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id }); + assert.match(unreachable.checks.find((item) => item.id === 'server.status.reachable').detail, /HTTP 503/i); + assert.match(unreachable.checks.find((item) => item.id === 'server.health').detail, /HTTP 500/i); + assert.match(unreachable.checks.find((item) => item.id === 'git.repository').detail, /checkout corrupt/i); + + status = { reachable: true, ok: true, repository: 'other/app', environment: 'staging', liveSha: null }; + const mismatch = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id }); + const identity = mismatch.checks.find((item) => item.id === 'server.status.identity'); + assert.equal(identity.status, 'fail'); + assert.equal(identity.required, true); + assert.match(mismatch.checks.find((item) => item.id === 'server.status.reachable').detail, /no live SHA/i); +}); diff --git a/tests/server-inventory-branches.test.mjs b/tests/server-inventory-branches.test.mjs new file mode 100644 index 0000000..3211df0 --- /dev/null +++ b/tests/server-inventory-branches.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + parseServerInventory, buildWorkloadInventory, inventoryContainerMatch, remoteIdentity, + stableWorkloadId, profileMatchesWorkload, sanitizeLegacyContainer, safeRelativeToBase, + canonicalServerAppdataPath, deploymentRootCandidate, +} = require('../src/main/server-inventory.cjs'); + +const b64 = (value) => Buffer.from(String(value)).toString('base64'); + +test('inventory parser handles every evidence record and ignores malformed payloads', () => { + const legacy = { Id: 'legacy', Name: '/App', Config: { Image: 'app:1', Labels: { 'com.docker.compose.project': 'app' } }, State: { Running: true, Status: 'running', Health: { Status: 'healthy' } }, Mounts: null }; + const safe = { id: 'safe', name: '/Safe', running: false, labels: null, mounts: null, ports: null, networks: null }; + const projects = [{ Name: 'app', Status: 'running(1)', ConfigFiles: '/mnt/user/appdata/App/compose.yml,/mnt/user/appdata/App/extra.yml' }, { name: '', configFiles: [] }]; + const output = [ + 'noise', '__FORGEFLOW_INVENTORY__', + `H\ttrue\tfalse\ttrue\ttrue\tfalse\ttrue\t${b64('Compose v2')}\t${b64('Linux')}`, + `R\t${b64('/mnt/user/appdata/App')}\t${b64('git@gitea.test:Owner/App.git')}\t${'a'.repeat(40)}\t${b64('main')}`, + `C\t${b64(JSON.stringify([legacy, null]))}`, + `C\t${b64(JSON.stringify(safe))}`, + `C\t${b64('{bad json')}`, + `D\t${b64('App')}\t${b64('/templates/App.xml')}\t${b64('http://app')}\t${b64('/icon.png')}\t${b64('/bin/bash')}\t${b64('app:1')}\t${b64('bridge')}`, + `P\t${b64(JSON.stringify(projects))}`, + `P\t${b64(JSON.stringify({ name: 'single', status: 'exited', config_files: ['single.yml'] }))}`, + `Y\t${b64('/mnt/user/appdata/App/')}\t${b64('compose.yml\ncompose.prod.yml\n')}\t${b64('app')}\t${b64('web\nworker')}\t${b64('app:1')}\ttrue\t${b64('')}`, + `W\t${b64('partial docker inspect failure')}`, + 'UNKNOWN\tignored', + ].join('\n'); + const parsed = parseServerInventory(output); + assert.deepEqual(parsed.capabilities, { docker: true, compose: false, git: true, tar: true, checksum: false, baseWritable: true, composeVersion: 'Compose v2', platform: 'Linux' }); + assert.equal(parsed.checkouts.length, 1); + assert.equal(parsed.containers.length, 2); + assert.equal(parsed.containers[0].health, 'healthy'); + assert.deepEqual(parsed.containers[1].labels, {}); + assert.equal(parsed.dockerMan[0].templatePath, '/templates/App.xml'); + assert.equal(parsed.composeProjects.length, 2); + assert.deepEqual(parsed.composeProjects[0].configFiles, ['/mnt/user/appdata/App/compose.yml', '/mnt/user/appdata/App/extra.yml']); + assert.deepEqual(parsed.composeDefinitions[0].services, ['web', 'worker']); + assert.deepEqual(parsed.warnings, ['partial docker inspect failure']); + assert.throws(() => parseServerInventory('ordinary output'), /did not return/i); +}); + +test('server path normalization keeps deployments inside canonical appdata', () => { + assert.equal(safeRelativeToBase('/mnt/user/appdata/', '/mnt/user/appdata/App/'), 'App'); + for (const value of ['', '/mnt/user/appdata', '/mnt/user/appdata/../etc', '/other/App']) assert.equal(safeRelativeToBase('/mnt/user/appdata', value), ''); + assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/cache/appdata/App'), '/mnt/user/appdata/App'); + assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/disk2/appdata/App/data'), '/mnt/user/appdata/App/data'); + assert.equal(canonicalServerAppdataPath('', '/mnt/user/appdata/App'), '/mnt/user/appdata/App'); + assert.equal(canonicalServerAppdataPath('/custom', '/outside/path'), '/outside/path'); + assert.equal(canonicalServerAppdataPath('/custom', ''), ''); + assert.equal(deploymentRootCandidate('App/source-pre-abcdef1/source'), 'App'); + assert.equal(deploymentRootCandidate('App/.forgeflow/incoming'), 'App'); +}); + +test('profile matching requires the same server and accepts each stable identity form', () => { + const workload = { serverId: 'server', workloadId: 'workload', selector: { kind: 'compose', composeProject: 'app' }, compose: { project: 'app', workingDir: '/apps/App' }, remoteFolderCandidate: 'App', containers: [{ name: 'app-web' }] }; + assert.equal(profileMatchesWorkload(null, workload), false); + assert.equal(profileMatchesWorkload({ provider: 'gitea-actions', serverId: 'server' }, workload), false); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'other' }, workload), false); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { workloadId: 'workload' } }, workload), true); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { selector: workload.selector } }, workload), true); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app' }, workload), true); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', composeWorkingDir: '/other' }, workload), false); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', remoteFolder: 'App' }, workload), true); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', containerName: 'app-web' }, workload), true); + assert.equal(stableWorkloadId('server', workload.selector), stableWorkloadId('server', workload.selector)); +}); + +test('container matching prioritizes working directory, mounts, provenance and stable names', () => { + const checkout = { root: '/apps/App', remote: 'git@gitea.test:Owner/App.git' }; + const repository = { name: 'App' }; + const base = { running: true, labels: {}, mounts: [], name: 'different' }; + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project.working_dir': '/apps/App/' } }), 100); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, mounts: [{ Source: '/apps/App/data' }] }), 90); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'org.opencontainers.image.source': 'https://gitea.test/Owner/App' } }), 85); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project': 'app' } }), 70); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, name: '/APP' }), 60); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, running: false }), 0); + assert.equal(inventoryContainerMatch(checkout, repository, base), 0); + assert.equal(remoteIdentity(''), ''); +}); + +test('workload builder merges runtime, Compose file and DockerMan evidence without backups', () => { + const labels = { + 'com.docker.compose.project': 'app', + 'com.docker.compose.project.working_dir': '/mnt/cache/appdata/App', + 'com.docker.compose.project.config_files': '/mnt/cache/appdata/App/compose.yml', + 'com.docker.compose.service': 'web', + 'tech.itworx.forgeflow.repository': 'git@gitea.test:Owner/App.git', + 'tech.itworx.forgeflow.commit': 'b'.repeat(40), + 'tech.itworx.forgeflow.branch': 'main', + }; + const inventory = { + containers: [ + { id: 'web', name: 'app-web', image: 'registry/app:1', imageId: 'image', running: true, status: 'running', health: 'unhealthy', labels, ports: { '8080/tcp': null, '3000/udp': [{ HostIp: '0.0.0.0', HostPort: '3000' }] }, mounts: [{ Type: 'bind', Source: '/mnt/disk1/appdata/App/data', Destination: '/data', RW: false }], networks: { frontend: {} }, restartPolicy: 'always' }, + { id: 'worker', name: 'app-worker', image: 'registry/worker:1', imageId: 'worker', running: false, status: 'exited', health: null, labels: { ...labels, 'com.docker.compose.service': 'worker' }, ports: {}, mounts: [], networks: {}, restartPolicy: '' }, + ], + checkouts: [{ root: '/mnt/user/appdata/App', remote: 'git@gitea.test:Owner/App.git', liveSha: 'c'.repeat(40), branch: 'release' }], + composeProjects: [{ name: 'app', status: 'running', configFiles: [] }, { name: 'headless', status: 'exited', configFiles: ['/mnt/user/appdata/Headless/compose.yml'] }], + composeDefinitions: [ + { workingDir: '/mnt/user/appdata/App', configFiles: ['/mnt/user/appdata/App/compose.yml'], projectName: 'app', services: ['web', 'worker'], images: ['registry/app:1'], valid: true, error: '' }, + { workingDir: '/mnt/user/appdata/Backup/.forgeflow/releases/one', configFiles: ['compose.yml'], projectName: 'backup', services: [], images: [], valid: true }, + { workingDir: '/mnt/user/appdata/Standalone', configFiles: ['/mnt/user/appdata/Standalone/compose.yml'], projectName: '', services: ['api'], images: ['standalone:1'], valid: false, error: 'invalid compose' }, + ], + dockerMan: [ + { name: 'app-web', templatePath: '/templates/app.xml', webUiUrl: 'http://app', iconUrl: '/app.png', shell: '/bin/bash', repository: 'registry/app:1', network: 'frontend' }, + { name: 'template-only', templatePath: '/templates/template.xml', webUiUrl: '', iconUrl: '', shell: '', repository: 'template:1', network: 'bridge' }, + ], warnings: [], capabilities: {}, + }; + const repository = { fullName: 'Owner/App', name: 'App', cloneUrl: 'https://gitea.test/Owner/App.git' }; + const workloads = buildWorkloadInventory({ inventory, server: { id: 'server', name: 'Unraid', basePath: '/mnt/user/appdata' }, repositories: [repository], profiles: [{ id: 'profile', provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', repositoryFullName: 'Owner/App', adoptedFromServer: true }] }); + assert.equal(workloads.some((workload) => workload.displayName === 'backup'), false); + const app = workloads.find((workload) => workload.displayName === 'app'); + assert.equal(app.status, 'linked'); + assert.equal(app.runtime.running, true); + assert.equal(app.runtime.allRunning, false); + assert.equal(app.runtime.health, 'unhealthy'); + assert.equal(app.runtime.ports.length, 2); + assert.equal(app.containers[0].mounts[0].readOnly, true); + assert.equal(app.remoteFolderCandidate, 'App'); + assert.equal(app.candidates[0].exact, true); + assert.equal(app.link.source, 'automatic'); + const standalone = workloads.find((workload) => workload.displayName === 'Standalone'); + assert.equal(standalone.metadata.composeDefinitionValid, false); + assert.equal(standalone.metadata.composeDefinitionError, 'invalid compose'); + const template = workloads.find((workload) => workload.displayName === 'template-only'); + assert.equal(template.kind, 'dockerman-container'); + assert.equal(template.runtime.running, false); + assert.equal(template.metadata.shell, '/bin/sh'); +}); + +test('legacy container sanitizer applies safe defaults to partial Docker inspect data', () => { + assert.deepEqual(sanitizeLegacyContainer(null), { + id: '', name: '', image: '', imageId: '', running: false, status: '', health: null, + labels: { + 'com.docker.compose.project': '', 'com.docker.compose.project.working_dir': '', 'com.docker.compose.project.config_files': '', 'com.docker.compose.service': '', + 'org.opencontainers.image.source': '', 'org.opencontainers.image.revision': '', 'tech.itworx.forgeflow.repository': '', 'tech.itworx.forgeflow.commit': '', + 'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '', + }, ports: {}, mounts: [], networks: {}, restartPolicy: '', + }); +}); diff --git a/tests/update-service.test.mjs b/tests/update-service.test.mjs index 076caef..c542920 100644 --- a/tests/update-service.test.mjs +++ b/tests/update-service.test.mjs @@ -528,6 +528,22 @@ test("Windows release pipeline fails closed on signatures and emits provenance p assert.match(publisher, /sbom\.cdx\.json/); }); +test("production signing build supports classic and Azure identities but always fails closed", async () => { + const [pkg, validator, signedConfig] = await Promise.all([ + readFile(new URL("../package.json", import.meta.url), "utf8"), + readFile(new URL("../scripts/validate-signing-environment.mjs", import.meta.url), "utf8"), + readFile(new URL("../scripts/signed-electron-builder-config.cjs", import.meta.url), "utf8"), + ]); + assert.match(pkg, /dist:win:signed/); + assert.match(validator, /FORGEFLOW_SIGNED_RELEASE/); + assert.match(validator, /WIN_CSC_LINK/); + assert.match(validator, /FORGEFLOW_AZURE_CERTIFICATE_PROFILE/); + assert.match(validator, /exact certificate subject/); + assert.match(signedConfig, /forceCodeSigning:\s*true/); + assert.match(signedConfig, /azureSignOptions/); + assert.match(signedConfig, /timestamp\.acs\.microsoft\.com/); +}); + test("binary update helper verifies, waits, applies and records restart state", async () => { const helper = await readFile( new URL("../scripts/apply-binary-update.ps1", import.meta.url),