test: cover the deployment and deploy-key paths, and gate coverage per module

deployment-service.cjs sat at 52% and unraid-deploy-key-host.cjs at 39% of its
functions, both hidden behind a healthy aggregate. They are now at 100% lines
and functions, tested through real HTTP endpoints and by intercepting the shell
script the key host sends, rather than by mocking the boundary away.

What is pinned down: a successful workflow run still fails when the server
cannot prove it runs that exact commit; a rollback ends as rolled-back rather
than success; an unreachable status endpoint is never treated as healthy; a
failed poll is recorded on the operation instead of losing it; deploy keys stay
repository-scoped under the server base path with a pinned host key; promotion
verifies the candidate before swapping atomically; and revocation moves key
material to recovery instead of deleting it.

Two assumptions turned out to be wrong and the tests follow the real behaviour:
the previous-SHA check runs before the already-live check, and a rollback
against an unreachable endpoint surfaces the underlying network error.

Covering clone-target exposed a real defect: a remote ending in "....git"
yielded the folder name "...". Windows strips trailing dots, so that resolves
back to the project root itself, past an escape guard that only looks for "..".
A dots-only name now falls back to "repository", consistent with how an empty
name was already handled. As a side effect "." and ".." resolve to a usable
folder instead of raising an error.

Coverage gates: the aggregate moves to 85/85/68, and a new per-module gate
(60 statements, 50 functions, 36 branches) stops a single module from silently
collapsing behind the total. It reuses the data from the first run, so the
suite is not executed twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-23 14:32:12 +02:00
co-authored by Claude Opus 5
parent 9260d35957
commit a5666e95f2
8 changed files with 898 additions and 154 deletions
+29 -25
View File
@@ -1,7 +1,7 @@
ForgeFlow 0.10.12 source manifest ForgeFlow 0.10.12 source manifest
SHA-256 BYTES PATH SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.) (The manifest excludes itself, dependencies and generated release artifacts.)
cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml 61f37822ae5502219a38b2eaf23fdcb611875f0e675efb4abe6157c9f072c0cc 937 .gitea/workflows/quality.yml
4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore 4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore
f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc
d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml
@@ -95,7 +95,7 @@ b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md 4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md
4bffda594058697345569d937d7a524f094ac85a0f338f0ef18fcf3f94d8c299 1292 eslint.config.js 73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 eslint.config.js
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml 4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy 577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy
@@ -107,15 +107,15 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
d7cae22a646c8f06f339db799f2b9921ba1799cdc44e5d05578c04aaace103ab 14357 main.cjs d7cae22a646c8f06f339db799f2b9921ba1799cdc44e5d05578c04aaace103ab 14357 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
198a8a51705fcea2ddf716558a49e06df5a8181c5476709c474d183b64890ca0 179808 package-lock.json 198a8a51705fcea2ddf716558a49e06df5a8181c5476709c474d183b64890ca0 179808 package-lock.json
84fa9b490e0845b1788d4f58741d733f0afd5946db5487dfba03a6e3018890cc 5782 package.json c579d4a9df6ca10c32a7b8168567bfdb333745438747430329a7a64041f1a4c4 5971 package.json
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs 1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
16d6c37007c6c14dbf541060535c594308758477b867fc2a190c2946d74022b9 12196 preload.cjs 16d6c37007c6c14dbf541060535c594308758477b867fc2a190c2946d74022b9 12196 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
42e2f375c2fa9bea138428b85eb2bb93fe8bf6e4a5fc0cd728f2c6713a019df7 10315 README.md 42e2f375c2fa9bea138428b85eb2bb93fe8bf6e4a5fc0cd728f2c6713a019df7 10315 README.md
8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json cac1e82069c0addef5cfc1fdb0dfed0a6b00408dbfe0ea95b24ed12e9e73ff13 14947 reports/architecture-audit.json
5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md 5f41108efe28200ec629d640e75bdf3ef326da98970ea0b941cbbf7ee9a83c61 1343 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1 d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1 404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1
@@ -133,21 +133,22 @@ e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs 0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs 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 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs 86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.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 c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-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 e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs 3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs 3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs 2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
f263ab067edea8dfeb962958d045ed4ab20b49cbeb965936f4ffa8c1dd0b7d59 25905 src/main/ipc.cjs 0e01dfeb9ad230ee13ee913e17b577b1d34507b9b436ebf849fa05312e470d46 24398 src/main/ipc.cjs
0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs 26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
b80357dd1f0aa18022d92db85b6cc8f29bc691ef11f9a0e90b4886ae5e19c763 12543 src/main/ipc/deployment-handlers.cjs
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
253d4f027de09e1e8fd46b40ef0da4eaf8350c0edfbf069b0a3429d712c7e8c4 15929 src/main/ipc/repository-handlers.cjs 253d4f027de09e1e8fd46b40ef0da4eaf8350c0edfbf069b0a3429d712c7e8c4 15929 src/main/ipc/repository-handlers.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
@@ -155,18 +156,18 @@ dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs 720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs c629efa832a806409954ab5a4986bf88606ce5fa5278bb1885a76222d1cb6faf 6441 src/main/repository-monitor.cjs
e48ac82e3a618459170969a6370c8a2d667594cd0b1915ccece38ba3f7eda6df 10303 src/main/repository-service.cjs 6393583911263575c6e2a19d9baab6e638cce90252c386b0a5144f2fb6f81f15 12154 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs 5830cf038c2404272b743657bfb4ce57c700e2f17b4f063d06afb80c9130bcba 14863 src/main/ssh-service.cjs
5394d16c1ceceef700a4b2a47cda088713e24c2e42a4bf4f86b1fa6006e796a2 25877 src/main/unraid-access-methods.cjs 19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs
5ba5a90ad9b7a0d05dd64fa64968cbdd7271bec4df6971f9685a9dd411e4fad4 9536 src/main/unraid-deploy-key-host.cjs 5ba5a90ad9b7a0d05dd64fa64968cbdd7271bec4df6971f9685a9dd411e4fad4 9536 src/main/unraid-deploy-key-host.cjs
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.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 a2fa253bb2664ad7e430d7a4cd4c4434b89e6c54c19c7b9e4f53fe08485339d3 42652 src/main/unraid-inventory-methods.cjs
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs 2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-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 a387ac9735624ef89d964ea59e035dae256b9915ba2cee22ae5cbc2894f5808a 22000 src/main/update-service.cjs
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
@@ -175,21 +176,21 @@ d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706
4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js 4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js
cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.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 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.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 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 06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js 870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
81b0155dd51c7bc8871fd29bd7fdb28857cf11a86ed6d6953238c04c2774800c 20033 src/renderer/mock-repository-bridge.js 81b0155dd51c7bc8871fd29bd7fdb28857cf11a86ed6d6953238c04c2774800c 20033 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js 94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
21b8f3228bddd6da06613857763282d1f88f4eeab3d063f3a63395d7b567f2e4 80004 src/renderer/styles.css 21b8f3228bddd6da06613857763282d1f88f4eeab3d063f3a63395d7b567f2e4 80004 src/renderer/styles.css
8bc794e191aa22a13cbbc89c9b3882e0c3070e1365a7c02cfe381a62d81877d9 101549 src/renderer/views.js 48dbcdeb844ae991f02ee38f1a6993df27dcd7efe032c3dbc529ece315c68dfb 102537 src/renderer/views.js
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs c7c204360aba6a9a2cf1b0cd80f7aa13ddd212ecea0ed8b385e4c4cc40bfbefc 1383 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs 2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
@@ -202,11 +203,14 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1 058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
e8802b78b94af7245f7752e0a5f8697064aad2aae8bd002195ae052d69e59670 11619 tests/browser/forgeflow.spec.mjs 4018fa3ba329b02af38efc67e3e03cef4ff8f422eddacb47a18979fedd04e1c7 15344 tests/browser/forgeflow.spec.mjs
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs 0d146d3b3428522df02bfacf80332ca6db065cb05666c0e02114885b8d51dea1 5858 tests/clone-target.test.mjs
ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.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 b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
57d93ac176cc6ff0850b38fcc6ec5be0e8eaca7e457a5a188bf86fa4a38d5b45 24378 tests/deployment-operations.test.mjs
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs 1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
@@ -226,8 +230,8 @@ caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs 0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
d4980aa7d2d364793f38771011383348be472d5447b6ee88a4d9e5f3a3b3ffa9 9911 tests/renderer-workflow.test.mjs d4980aa7d2d364793f38771011383348be472d5447b6ee88a4d9e5f3a3b3ffa9 9911 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs e3c6e0f5c9351fce2ccf96b8abb15de404a0f935633df0b5b1c3fefa207c723b 3993 tests/repository-monitor.test.mjs
32056b36a7e78701e8b9253ea78646ab4ecd88c5788841a1459177417ecb2e08 10441 tests/repository-service.test.mjs 5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs 12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
+2 -1
View File
@@ -11,7 +11,8 @@
"demo": "node scripts/serve-demo.mjs", "demo": "node scripts/serve-demo.mjs",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"lint": "eslint .", "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", "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": "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", "dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
+138 -124
View File
@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-08-01T15:23:33.157Z", "generatedAt": "2026-08-23T12:21:43.185Z",
"thresholds": { "thresholds": {
"preferredMaximumLines": 750, "preferredMaximumLines": 750,
"justificationRequiredLines": 1000 "justificationRequiredLines": 1000
@@ -22,6 +22,22 @@
], ],
"hotspotScore": 131 "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", "file": "src/main/server-inventory.cjs",
"lines": 578, "lines": 578,
@@ -38,31 +54,31 @@
"hotspotScore": 119 "hotspotScore": 119
}, },
{ {
"file": "src/renderer/app.js", "file": "src/main/git-service.cjs",
"lines": 676, "lines": 687,
"branches": 75, "branches": 111,
"functions": 113, "functions": 118,
"ipcHandlers": 0,
"responsibilities": [
"git",
"security"
],
"hotspotScore": 111
},
{
"file": "src/main/unraid-inventory-methods.cjs",
"lines": 708,
"branches": 76,
"functions": 93,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
"deployment", "deployment",
"git", "git",
"renderer",
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 115 "hotspotScore": 106
},
{
"file": "src/main/git-service.cjs",
"lines": 632,
"branches": 98,
"functions": 109,
"ipcHandlers": 0,
"responsibilities": [
"git"
],
"hotspotScore": 98
} }
], ],
"mixedResponsibilityModules": [ "mixedResponsibilityModules": [
@@ -81,6 +97,22 @@
], ],
"hotspotScore": 131 "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", "file": "src/main/server-inventory.cjs",
"lines": 578, "lines": 578,
@@ -97,41 +129,23 @@
"hotspotScore": 119 "hotspotScore": 119
}, },
{ {
"file": "src/renderer/app.js", "file": "src/main/unraid-inventory-methods.cjs",
"lines": 676, "lines": 708,
"branches": 75, "branches": 76,
"functions": 113, "functions": 93,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
"deployment", "deployment",
"git", "git",
"renderer",
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 115 "hotspotScore": 106
},
{
"file": "src/main/ipc.cjs",
"lines": 749,
"branches": 55,
"functions": 79,
"ipcHandlers": 27,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"renderer",
"security",
"updates"
],
"hotspotScore": 105
}, },
{ {
"file": "src/main/unraid-deployment-methods.cjs", "file": "src/main/unraid-deployment-methods.cjs",
"lines": 577, "lines": 583,
"branches": 69, "branches": 69,
"functions": 31, "functions": 31,
"ipcHandlers": 0, "ipcHandlers": 0,
@@ -145,10 +159,10 @@
"hotspotScore": 99 "hotspotScore": 99
}, },
{ {
"file": "src/renderer/views.js", "file": "src/renderer/dialogs.js",
"lines": 737, "lines": 419,
"branches": 55, "branches": 58,
"functions": 156, "functions": 80,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
@@ -158,18 +172,35 @@
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 95 "hotspotScore": 98
}, },
{ {
"file": "src/main/unraid-inventory-methods.cjs", "file": "src/renderer/views.js",
"lines": 591, "lines": 749,
"branches": 61, "branches": 56,
"functions": 76, "functions": 159,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
"deployment", "deployment",
"git", "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", "security",
"updates" "updates"
], ],
@@ -204,11 +235,28 @@
], ],
"hotspotScore": 87 "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", "file": "src/renderer/actions/deployment-profile.js",
"lines": 402, "lines": 407,
"branches": 56, "branches": 56,
"functions": 43, "functions": 44,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
@@ -220,27 +268,25 @@
"hotspotScore": 86 "hotspotScore": 86
}, },
{ {
"file": "main.cjs", "file": "src/main/config-store.cjs",
"lines": 443, "lines": 665,
"branches": 33, "branches": 52,
"functions": 53, "functions": 89,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
"deployment", "deployment",
"git", "git",
"ipc",
"renderer",
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 83 "hotspotScore": 82
}, },
{ {
"file": "src/main/unraid-preflight-methods.cjs", "file": "src/main/unraid-preflight-methods.cjs",
"lines": 594, "lines": 623,
"branches": 38, "branches": 40,
"functions": 42, "functions": 47,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
@@ -250,22 +296,7 @@
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 78 "hotspotScore": 80
},
{
"file": "src/main/config-store.cjs",
"lines": 652,
"branches": 47,
"functions": 86,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 77
}, },
{ {
"file": "src/main/unraid-runtime-methods.cjs", "file": "src/main/unraid-runtime-methods.cjs",
@@ -297,27 +328,11 @@
], ],
"hotspotScore": 76 "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", "file": "src/main/unraid-access-methods.cjs",
"lines": 448, "lines": 462,
"branches": 43, "branches": 43,
"functions": 42, "functions": 41,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
@@ -359,7 +374,7 @@
}, },
{ {
"file": "src/main/ssh-service.cjs", "file": "src/main/ssh-service.cjs",
"lines": 333, "lines": 332,
"branches": 41, "branches": 41,
"functions": 69, "functions": 69,
"ipcHandlers": 0, "ipcHandlers": 0,
@@ -445,7 +460,7 @@
}, },
{ {
"file": "src/main/ipc/deployment-handlers.cjs", "file": "src/main/ipc/deployment-handlers.cjs",
"lines": 285, "lines": 286,
"branches": 13, "branches": 13,
"functions": 38, "functions": 38,
"ipcHandlers": 24, "ipcHandlers": 24,
@@ -493,7 +508,7 @@
}, },
{ {
"file": "src/renderer/mock-deployment-bridge.js", "file": "src/renderer/mock-deployment-bridge.js",
"lines": 699, "lines": 701,
"branches": 11, "branches": 11,
"functions": 87, "functions": 87,
"ipcHandlers": 0, "ipcHandlers": 0,
@@ -539,9 +554,9 @@
}, },
{ {
"file": "src/main/unraid-state-methods.cjs", "file": "src/main/unraid-state-methods.cjs",
"lines": 283, "lines": 294,
"branches": 15, "branches": 16,
"functions": 20, "functions": 21,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
@@ -550,7 +565,7 @@
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 45 "hotspotScore": 46
}, },
{ {
"file": "src/main/preflight-service.cjs", "file": "src/main/preflight-service.cjs",
@@ -566,6 +581,20 @@
], ],
"hotspotScore": 44 "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", "file": "src/renderer/mock-bridge.js",
"lines": 590, "lines": 590,
@@ -594,20 +623,6 @@
], ],
"hotspotScore": 33 "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", "file": "src/main/deployment-identity.cjs",
"lines": 36, "lines": 36,
@@ -669,24 +684,23 @@
"ipcHotspots": [ "ipcHotspots": [
{ {
"file": "src/main/ipc.cjs", "file": "src/main/ipc.cjs",
"lines": 749, "lines": 694,
"branches": 55, "branches": 51,
"functions": 79, "functions": 70,
"ipcHandlers": 27, "ipcHandlers": 27,
"responsibilities": [ "responsibilities": [
"inventory", "inventory",
"deployment", "deployment",
"git", "git",
"ipc", "ipc",
"renderer",
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 105 "hotspotScore": 91
}, },
{ {
"file": "src/main/ipc/deployment-handlers.cjs", "file": "src/main/ipc/deployment-handlers.cjs",
"lines": 285, "lines": 286,
"branches": 13, "branches": 13,
"functions": 38, "functions": 38,
"ipcHandlers": 24, "ipcHandlers": 24,
+4 -3
View File
@@ -1,6 +1,6 @@
# ForgeFlow architecture audit # 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 ## Files above 750 lines
@@ -19,9 +19,10 @@ No findings.
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities | | File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---| |---|---:|---:|---:|---:|---|
| `src/renderer/actions/shell.js` | 518 | 101 | 86 | 0 | inventory, deployment, git, renderer, updates | | `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/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` | 687 | 111 | 118 | 0 | git, security |
| `src/main/git-service.cjs` | 632 | 98 | 109 | 0 | git | | `src/main/unraid-inventory-methods.cjs` | 708 | 76 | 93 | 0 | inventory, deployment, git, security, updates |
## Interpretation ## Interpretation
+5 -1
View File
@@ -5,7 +5,11 @@ const path = require('node:path');
function cloneDirectoryName(remoteUrl) { function cloneDirectoryName(remoteUrl) {
const raw = String(remoteUrl || '').trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, ''); const raw = String(remoteUrl || '').trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, '');
const segment = raw.split(/[\\/:]/).filter(Boolean).at(-1) || 'repository'; 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) { function resolveCloneTarget(workspaceRoot, remoteUrl) {
+29
View File
@@ -26,6 +26,35 @@ test('resolves the automatic clone target inside the configured project root', (
assert.equal(plan.directoryName, 'portfolio'); 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) => { test('clone target inspection accepts missing and empty destinations', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-')); const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-'));
t.after(() => fs.rm(root, { recursive: true, force: true })); t.after(() => fs.rm(root, { recursive: true, force: true }));
+194
View File
@@ -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 '<path>'.new '<path>' rather than mv '<path>.new' '<path>'.
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" });
});
+497
View File
@@ -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);
}
});