@oj-module/cli 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/devkit/README.md +14 -3
  2. package/devkit/SKILL.md +16 -4
  3. package/devkit/framework-dev.md +8 -6
  4. package/devkit/manual.md +243 -23
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +143 -8
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/args.ts +67 -0
  10. package/src/build.ts +11 -3
  11. package/src/bundle.ts +197 -0
  12. package/src/contract/__tests__/handler-scan.test.ts +82 -0
  13. package/src/contract/check.ts +2 -1
  14. package/src/contract/handler-scan.ts +32 -0
  15. package/src/deterministic-tar.ts +120 -0
  16. package/src/dev.ts +4 -2
  17. package/src/enable.ts +28 -0
  18. package/src/index.ts +63 -2
  19. package/src/info.ts +3 -2
  20. package/src/init.ts +147 -14
  21. package/src/install.ts +268 -0
  22. package/src/keygen.ts +88 -0
  23. package/src/layout.ts +31 -0
  24. package/src/lifecycle/__tests__/authoring.test.ts +109 -0
  25. package/src/lifecycle/__tests__/bundle.test.ts +150 -0
  26. package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
  27. package/src/lifecycle/__tests__/fixtures/README.md +59 -0
  28. package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
  29. package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
  30. package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
  31. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
  32. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
  33. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
  36. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
  37. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
  38. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
  39. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
  40. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
  41. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
  42. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
  43. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
  44. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
  45. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
  46. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
  47. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
  48. package/src/lifecycle/__tests__/helpers.ts +78 -0
  49. package/src/lifecycle/__tests__/install.test.ts +514 -0
  50. package/src/lifecycle/__tests__/keygen.test.ts +61 -0
  51. package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
  52. package/src/lifecycle/__tests__/publish.test.ts +80 -0
  53. package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
  54. package/src/lifecycle/__tests__/sign.test.ts +131 -0
  55. package/src/lifecycle/__tests__/smoke.test.ts +61 -0
  56. package/src/lifecycle/__tests__/trust.test.ts +177 -0
  57. package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
  58. package/src/lifecycle/keygen.ts +40 -0
  59. package/src/lifecycle/sign.ts +74 -0
  60. package/src/manifest-contract.ts +696 -0
  61. package/src/oj-cert.ts +19 -0
  62. package/src/oj-cmd.ts +3 -3
  63. package/src/preview.ts +2 -2
  64. package/src/publish.ts +150 -0
  65. package/src/sign.ts +128 -0
  66. package/src/site.ts +146 -0
  67. package/src/trust.ts +116 -0
  68. package/src/uninstall.ts +29 -0
  69. package/src/usage.ts +35 -1
  70. package/templates/README.md +1 -1
  71. package/templates/api/config.yaml +47 -0
  72. package/templates/api/seed.mysql.sql +1 -1
  73. package/templates/api/seed.pg.sql +1 -1
  74. package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
  75. package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
  76. package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
  77. package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
  78. package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
  79. package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
  80. package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
  81. package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
  82. package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
  83. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
  84. package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
  85. package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
  86. package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
  87. package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
  88. package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
  89. package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
  90. package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
  91. package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
  92. package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
  93. package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
  94. package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
  95. package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
  96. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
  97. package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
  98. package/templates/api/src/_platform/_shared/service/access.ts +2 -2
  99. package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
  100. package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
  101. package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
  102. package/templates/api/src/_platform/manifest.yaml +1 -0
  103. package/templates/api/src/_platform/market/api.ts +883 -0
  104. package/templates/api/src/_platform/market/bootstrap.ts +39 -0
  105. package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
  106. package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
  107. package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
  108. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
  109. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
  110. package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
  111. package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
  112. package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
  113. package/templates/api/src/_platform/schema.yaml +21 -4
  114. package/templates/api/src/_platform/seed.sql +2 -2
  115. package/templates/api/src/_platform/solutions/api.ts +212 -0
  116. package/templates/api/src/demo/manifest.yaml +5 -0
  117. package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
  118. package/templates/api/src/demo/schema.yaml +11 -0
  119. package/templates/api/src/demo/seed.sql +5 -0
  120. package/templates/api/src/demo/todos/api.ts +59 -0
  121. package/templates/api/src/home/line/api.ts +57 -0
  122. package/templates/api/src/home/manifest.yaml +6 -0
  123. package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
  124. package/templates/api/src/home/pie/api.ts +40 -0
  125. package/templates/api/src/home/schema.yaml +14 -0
  126. package/templates/api/src/home/seed.sql +70 -0
  127. package/templates/api/src/system/oplog/api.ts +44 -2
  128. package/templates/api/tests/fixture.ts +16 -0
  129. package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
  130. package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
  131. package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
  132. package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
  133. package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
  134. package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
  135. package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
  136. package/templates/api/tests/lifecycle/register.spec.ts +194 -0
  137. package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
  138. package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
  139. package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
  140. package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
  141. package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
  142. package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
  143. package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
  144. package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
  145. package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
  146. package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
  147. package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
  148. package/templates/api/tests/rbac-settings.spec.ts +8 -0
  149. package/templates/init.include.yaml +56 -0
  150. package/templates/openapi.json +527 -0
  151. package/templates/package.json +52 -0
  152. package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
  153. package/templates/web/src/_platform/__client/api.ts +335 -0
  154. package/templates/web/src/about/entry.ts +42 -0
  155. package/templates/web/src/about/locales/en-US.json +16 -0
  156. package/templates/web/src/about/locales/zh-CN.json +16 -0
  157. package/templates/web/src/about/pages/constants.ts +20 -0
  158. package/templates/web/src/about/pages/index.tsx +135 -0
  159. package/templates/web/src/access/entry.ts +74 -0
  160. package/templates/web/src/access/locales/en-US.json +46 -0
  161. package/templates/web/src/access/locales/zh-CN.json +46 -0
  162. package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
  163. package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
  164. package/templates/web/src/access/pages/button-control/index.tsx +181 -0
  165. package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
  166. package/templates/web/src/access/pages/page-control/index.tsx +142 -0
  167. package/templates/web/src/demo/__client/api.schemas.ts +20 -0
  168. package/templates/web/src/demo/__client/api.ts +89 -0
  169. package/templates/web/src/exception/entry.ts +80 -0
  170. package/templates/web/src/exception/locales/en-US.json +13 -0
  171. package/templates/web/src/exception/locales/zh-CN.json +13 -0
  172. package/templates/web/src/exception/pages/403/index.tsx +30 -0
  173. package/templates/web/src/exception/pages/404/index.tsx +30 -0
  174. package/templates/web/src/exception/pages/500/index.tsx +30 -0
  175. package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
  176. package/templates/web/src/home/__client/api.schemas.ts +22 -0
  177. package/templates/web/src/home/__client/api.ts +113 -0
  178. package/templates/web/src/login/locales/en-US.json +6 -0
  179. package/templates/web/src/login/locales/zh-CN.json +6 -0
  180. package/templates/web/src/login/pages/login.tsx +67 -18
  181. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
  182. package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
  183. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
  184. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
  185. package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
  186. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
  187. package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
  188. package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
  189. package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
  190. package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
  191. package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
  192. package/templates/web/src/oj-market/api/client.ts +135 -0
  193. package/templates/web/src/oj-market/api/context.ts +11 -0
  194. package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
  195. package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
  196. package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
  197. package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
  198. package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
  199. package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
  200. package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
  201. package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
  202. package/templates/web/src/oj-market/entry.ts +27 -0
  203. package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
  204. package/templates/web/src/oj-market/locales/en-US.json +100 -0
  205. package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
  206. package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
  207. package/templates/web/src/oj-market/types.ts +84 -0
  208. package/templates/web/src/oj-market/utils/poll.ts +36 -0
  209. package/templates/web/src/oj-market/utils/semver.ts +14 -0
  210. package/templates/web/src/outside/entry.ts +80 -0
  211. package/templates/web/src/outside/locales/en-US.json +10 -0
  212. package/templates/web/src/outside/locales/zh-CN.json +10 -0
  213. package/templates/web/src/personal-center/entry.ts +1 -10
  214. package/templates/web/src/personal-center/locales/en-US.json +1 -5
  215. package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
  216. package/templates/web/src/route-nest/entry.ts +73 -0
  217. package/templates/web/src/route-nest/locales/en-US.json +9 -0
  218. package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
  219. package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
  220. package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
  221. package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
  222. package/templates/web/src/system/__client/api.schemas.ts +12 -0
  223. package/templates/web/src/system/__client/api.ts +25 -1
  224. package/templates/web/src/system/pages/oplog/index.tsx +1 -1
  225. package/templates/web/src/system/pages/roles/index.tsx +1 -0
  226. package/templates/web/src/system/pages/settings/index.tsx +1 -1
  227. package/templates/web/src/system/pages/users/index.tsx +1 -1
  228. package/templates/web.config.ts +32 -40
  229. package/vendor/host-versions.json +3 -3
  230. package/templates/pnpm-workspace.yaml +0 -14
  231. package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
@@ -0,0 +1,971 @@
1
+ // BDD:编排端点 + 市场索引(P1-12,R9/ISP)。
2
+ // 四端点同挂 _platform/market 组(oj v0.1.64 嵌套 handler 组,notification/api.ts 同形态):
3
+ // POST /api/_platform/market/install superadmin——runInstall 编排(端点不重复编排,P1-10 状态机负责推进)
4
+ // GET /api/_platform/market/task?id= superadmin——任务轮询消费面(§10.2;内容写侧已脱敏,读侧无秘密可泄)
5
+ // GET /api/_platform/market/meta 登录即可——{apiVersion}(peerPlatform 预检)
6
+ // GET /api/_platform/market/index superadmin——CDN bundles.yaml(no-cache 拉取)+ 本地 state 对比
7
+ // 简报 `task/:id` 段参数形态在模板 http 面无先例(contacts/detail?id= 同款 query 惯例),备案于任务报告。
8
+ // 步骤面全 stub 注入(DIP):fetch/exec/reload 不真跑;register/smoke/audit 走真实内存库夹具。
9
+ /* eslint-disable test/prefer-lowercase-title -- 端点动词/路径是验收锚点,保留大写(installer.spec 同约定) */
10
+ import type { FsFace } from "../../src/_platform/_lifecycle/atomic";
11
+ import type { FetchFace } from "../../src/_platform/_lifecycle/steps/download";
12
+ import type { ExecFace } from "../../src/_platform/_lifecycle/steps/extract";
13
+ import type { ReloadFace } from "../../src/_platform/_lifecycle/steps/reload";
14
+ import type { MarketEnv } from "../../src/_platform/market/api";
15
+ import { createHash } from "node:crypto";
16
+ import { readFileSync } from "node:fs";
17
+ import * as fsp from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
+ import { parse } from "yaml";
23
+ import { defaultTestKey, nodeVerifyEd25519, signedArchiveFor } from "../../../../src/lifecycle/__tests__/helpers";
24
+ import { signIndex } from "../../../../src/publish";
25
+ import { readLock, writeLock } from "../../src/_platform/_lifecycle/lock";
26
+ import { createStateStore } from "../../src/_platform/_lifecycle/state";
27
+ import { getTask, writeTask } from "../../src/_platform/_lifecycle/tasks";
28
+ import { createTrustStore } from "../../src/_platform/_lifecycle/trust";
29
+ import { invalidateAllUsers } from "../../src/_platform/_shared/service/access";
30
+ import { createMarketApi, marketEnv, parseVersionsJson, versionsJsonPathOf } from "../../src/_platform/market/api";
31
+ import { installDb, MARKET_HISTORY_DDL } from "../fixture";
32
+
33
+ function hexToBytes(hex: string): Uint8Array {
34
+ const out = new Uint8Array(hex.length / 2);
35
+ for (let i = 0; i < out.length; i++)
36
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
37
+ return out;
38
+ }
39
+
40
+ const g = globalThis as any;
41
+
42
+ const ARCHIVE = new TextEncoder().encode("demo-archive-bytes\n");
43
+ const SHA256 = createHash("sha256").update(ARCHIVE).digest("hex");
44
+ /** 共享测试 key(P2-3:Form B 签名与信任库播种同一把;无私钥入仓) */
45
+ const TEST_KEY = defaultTestKey();
46
+ /** file 源签名侧车载荷(JSON .sig 文本字节) */
47
+ const SIG_BYTES = new TextEncoder().encode(JSON.stringify(signedArchiveFor(ARCHIVE, "demo-solution", "0.1.0", { keyPair: TEST_KEY }).sig));
48
+ const ENTRY = new TextEncoder().encode("console.log(\"demo entry\");\n");
49
+ const CSS = new TextEncoder().encode(".demo{color:red}\n");
50
+ const MIGRATE_LINE = JSON.stringify({ results: [{ module: "demo-solution", ok: true, applied: [{ version: 3, name: "create_todos" }] }] });
51
+
52
+ /** 十阶段表(契约附录 A stage 名逐字:lock_commit 为 D11①扩展组,audit 恒末位) */
53
+ const STAGE_ORDER = ["preflight", "downloading", "verifying", "extracting", "migrating", "lock_commit", "registering", "deploying_web", "reloading", "smoke", "audit"];
54
+
55
+ /** 末尾斜杠 CDN(顺带钉 base 归一化口径) */
56
+ const CDN = "https://cdn.example.com/solutions/";
57
+
58
+ function manifestFixture(version = "0.1.0"): any {
59
+ return {
60
+ name: "demo-solution",
61
+ version,
62
+ summary: "演示方案",
63
+ author: "oj-module",
64
+ changelog: [`${version} 变更条目`],
65
+ peerRuntime: ">=0.1.0",
66
+ archive: { file: `demo-solution-${version}.tgz`, sha256: SHA256, size: ARCHIVE.byteLength, signature: signedArchiveFor(ARCHIVE, "demo-solution", version, { keyPair: TEST_KEY }).sig },
67
+ api: { prefix: "/api/demo-solution", anonymous: ["demo-solution/ping"] },
68
+ web: { entry: `modules/demo-solution/${version}/entry.js`, css: [`modules/demo-solution/${version}/main.css`] },
69
+ routes: [
70
+ { path: "/demo-solution", category: "main", order: 200, title_key: "demo-solution:menu.main" },
71
+ { path: "/demo-solution/ping", category: "hidden", order: 205, title_key: "demo-solution:menu.ping" },
72
+ ],
73
+ permissions: [{ code: "demo-solution.view", title_key: "demo-solution:perm.view" }],
74
+ roles: [{ name: "demo-solution.admin", title_key: "demo-solution:role.admin", permissions: ["demo-solution.view"] }],
75
+ settings: {
76
+ groups: [{
77
+ group: "display",
78
+ label_key: "demo-solution:settings.group.display",
79
+ items: [{ key: "demo-solution.display.theme", type: "enum", options: ["dark", "light"] }],
80
+ }],
81
+ },
82
+ };
83
+ }
84
+
85
+ /** Form B CDN 索引(JSON 序列充当 yaml.parse 全局的载荷——解析面是 oj 全局,端点只管喂文本取对象) */
86
+ const BUNDLES = JSON.stringify({
87
+ generated_at: "2026-10-17T00:00:00Z",
88
+ cdn: CDN,
89
+ solutions: [
90
+ {
91
+ name: "demo-solution",
92
+ latest: "0.2.0",
93
+ versions: [
94
+ { version: "0.2.0", file: "demo-solution-0.2.0.tgz", sha256: SHA256, size: ARCHIVE.byteLength },
95
+ { version: "0.1.0", file: "demo-solution-0.1.0.tgz", sha256: SHA256, size: ARCHIVE.byteLength },
96
+ ],
97
+ },
98
+ { name: "wiki", latest: "1.3.0", versions: [{ version: "1.3.0", file: "wiki-1.3.0.tgz", sha256: "x", size: 1 }] },
99
+ ],
100
+ });
101
+ /** manifests.yaml 聚合数组(PRD §5.3:按 name+version 精确取) */
102
+ const MANIFESTS = JSON.stringify([manifestFixture("0.2.0"), manifestFixture("0.1.0")]);
103
+
104
+ let root: string;
105
+ let sharedRoot: string;
106
+ let staging: string;
107
+ let taskDir: string;
108
+ let siteModulesDir: string;
109
+ let modulesJsonPath: string;
110
+ let dbPath: string;
111
+ let dbo: any;
112
+
113
+ /** 捕获 json.ok/json.fail 出口(respond 收口后恰一次调用) */
114
+ let last: { status: number, msg?: string, data?: unknown } | null = null;
115
+
116
+ const fetchCalls: string[] = [];
117
+ const fetchInits: unknown[] = [];
118
+
119
+ const cdnFetch = ((url: string, init?: unknown) => {
120
+ fetchCalls.push(url);
121
+ fetchInits.push(init);
122
+ const text = url.endsWith("bundles.yaml")
123
+ ? BUNDLES
124
+ : url.endsWith("manifests.yaml")
125
+ ? MANIFESTS
126
+ : null;
127
+ if (text !== null) {
128
+ return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
129
+ }
130
+ if (url.endsWith(".tgz")) {
131
+ return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
132
+ }
133
+ // smoke 探针等其余 URL:连通即可
134
+ return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
135
+ }) as FetchFace;
136
+
137
+ function nodeFsFace(): FsFace {
138
+ return {
139
+ writeFile: (path, data) => fsp.writeFile(path, data),
140
+ readFile: path => fsp.readFile(path),
141
+ mkdir: (path, opts) => fsp.mkdir(path, opts),
142
+ remove: (path, opts) => fsp.rm(path, opts),
143
+ rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
144
+ copyFile: (src, dest) => fsp.copyFile(src, dest),
145
+ readdir: path => fsp.readdir(path),
146
+ };
147
+ }
148
+
149
+ /** bundle exec stub:tar list 返回 verbose listing、tar extract 写出 §2.9 全量夹具(端点要真解析)、oj migrate 返回 S1-6 合同 JSON */
150
+ function bundleExec(): ExecFace {
151
+ return {
152
+ run: async (argv) => {
153
+ if (argv[0] === "tar" && argv[1] === "-tvzf") {
154
+ const v = /demo-solution-([\w.-]+)\.tgz$/.exec(argv[2] ?? "")?.[1] ?? "0.1.0";
155
+ return { code: 0, stdout: `manifest.yaml\napi/demo-solution-${v}/manifest.yaml\napi/demo-solution-${v}/routes.js\napi/demo-solution-${v}/migrations/0001__init.sql\nweb/modules/demo-solution/${v}/entry.js\nweb/modules/demo-solution/${v}/main.css\n`, stderr: "" };
156
+ }
157
+ if (argv[0] === "tar" && argv[1] === "-xzf") {
158
+ const v = /demo-solution-([\w.-]+)\.tgz$/.exec(argv[2] ?? "")?.[1] ?? "0.1.0";
159
+ const dest = argv[4]!;
160
+ const apiDir = join(dest, "api", `demo-solution-${v}`, "migrations");
161
+ const dir = join(dest, "web", "modules", "demo-solution", v);
162
+ await fsp.mkdir(apiDir, { recursive: true });
163
+ await fsp.mkdir(dir, { recursive: true });
164
+ await fsp.writeFile(join(dest, "manifest.yaml"), JSON.stringify(manifestFixture(v)));
165
+ // oj 原生清单(api 模块目录内,desc === summary,C2-31 消费面)
166
+ await fsp.writeFile(join(dest, "api", `demo-solution-${v}`, "manifest.yaml"), JSON.stringify({ name: "demo-solution", desc: "演示方案", version: v }));
167
+ await fsp.writeFile(join(dest, "api", `demo-solution-${v}`, "routes.js"), "export {};\n");
168
+ await fsp.writeFile(join(apiDir, "0001__init.sql"), "CREATE TABLE note (id INTEGER PRIMARY KEY);\n");
169
+ await fsp.writeFile(join(dir, "entry.js"), ENTRY);
170
+ await fsp.writeFile(join(dir, "main.css"), CSS);
171
+ return { code: 0, stdout: "", stderr: "" };
172
+ }
173
+ if (argv[0] === "oj" && argv[1] === "migrate") {
174
+ return { code: 0, stdout: `${MIGRATE_LINE}\n`, stderr: "" };
175
+ }
176
+ return { code: 0, stdout: "", stderr: "" };
177
+ },
178
+ };
179
+ }
180
+
181
+ const reloadStub: ReloadFace = {
182
+ reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] }),
183
+ };
184
+
185
+ function makeEnv(overrides: Partial<MarketEnv> = {}): MarketEnv {
186
+ return {
187
+ sharedRoot,
188
+ staging,
189
+ taskDir,
190
+ statePath: join(sharedRoot, ".solutions", "state.yaml"),
191
+ trustPath: join(sharedRoot, ".solutions", "trusted-keys.yaml"),
192
+ dbPath,
193
+ siteModulesDir,
194
+ modulesJsonPath,
195
+ apiPrefix: "/api/demo-solution",
196
+ configPath: join(root, "config.yaml"),
197
+ cdnBase: async () => CDN,
198
+ fetch: cdnFetch,
199
+ exec: bundleExec(),
200
+ reload: reloadStub,
201
+ sha256Hex: d => createHash("sha256").update(d).digest("hex"),
202
+ verifyEd25519: nodeVerifyEd25519,
203
+ sha384B64: d => createHash("sha384").update(d).digest("base64"),
204
+ ledgerHead: null,
205
+ hostVersions: { "@oj-module/runtime": "0.1.77" },
206
+ ...overrides,
207
+ };
208
+ }
209
+
210
+ beforeEach(async () => {
211
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-market-"));
212
+ sharedRoot = join(root, "modules");
213
+ staging = join(sharedRoot, ".staging");
214
+ taskDir = join(sharedRoot, ".install-tasks");
215
+ siteModulesDir = join(root, "dist", "modules");
216
+ modulesJsonPath = join(root, "dist", "modules.json");
217
+ dbPath = join(root, "db.sqlite");
218
+ await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
219
+ // 站点 config.yaml 夹具(reloading 段 vars.ojm_install_seq 自增消费面;模板 config.yaml 随发形态)
220
+ await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n ojm_install_seq: 0\nauth:\n anonymous_paths:\n - /auth/login\n");
221
+ g.fs = nodeFsFace();
222
+ dbo = installDb();
223
+ dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
224
+ // 播种信任库(P2-3:Form B 签名夹具同一把 TEST_KEY;走真实 store)
225
+ await createTrustStore(join(sharedRoot, ".solutions", "trusted-keys.yaml"), { audit: () => {}, fingerprintOf: pk => createHash("sha256").update(hexToBytes(pk)).digest("hex") }).add("test.publisher", TEST_KEY.publicKeyHex);
226
+ g.yaml = { parse: async (t: string) => JSON.parse(t) };
227
+ g.http = { user: { id: 1 } };
228
+ g.json = {
229
+ ok: (data: unknown) => {
230
+ last = { status: 200, data };
231
+ },
232
+ fail: (status: number, msg: string) => {
233
+ last = { status, msg };
234
+ },
235
+ };
236
+ last = null;
237
+ fetchCalls.length = 0;
238
+ fetchInits.length = 0;
239
+ });
240
+
241
+ afterEach(async () => {
242
+ g.fs = undefined;
243
+ g.http = undefined;
244
+ g.json = undefined;
245
+ g.yaml = undefined;
246
+ g.db = undefined;
247
+ invalidateAllUsers();
248
+ await fsp.rm(root, { recursive: true, force: true });
249
+ });
250
+
251
+ describe("权限门(R9:市场编排 superadmin 独占,meta 登录即可)", () => {
252
+ it("非 superadmin 访问 install/task/index 一律 403(拒权不泄露存在性)", async () => {
253
+ g.http = { user: { id: 2 } };
254
+ const api = createMarketApi(makeEnv());
255
+ await api.install.post();
256
+ expect(last?.status).toBe(403);
257
+ await api.task.get();
258
+ expect(last?.status).toBe(403);
259
+ await api.index.get();
260
+ expect(last?.status).toBe(403);
261
+ });
262
+
263
+ it("meta 登录即可:匿名 401,任意登录用户得 {apiVersion}(peerPlatform 预检)", async () => {
264
+ const api = createMarketApi(makeEnv());
265
+ g.http = {};
266
+ await api.meta.get();
267
+ expect(last?.status).toBe(401);
268
+ g.http = { user: { id: 1 } };
269
+ await api.meta.get();
270
+ expect(last?.status).toBe(200);
271
+ expect(last?.data).toEqual({ apiVersion: "0.1.0" });
272
+ });
273
+
274
+ it("pair-pin:meta apiVersion === _platform/manifest.yaml version(手工镜像漂移防护,P2-9)", async () => {
275
+ // API_VERSION(market/api.ts)与 _platform/manifest.yaml version 为手工同步的双真源;
276
+ // 本测试经 meta 端点间接钉住 API_VERSION,漂移即红(peerPlatform 预检口径失真)。
277
+ const manifestPath = join(dirname(fileURLToPath(import.meta.url)), "../../src/_platform/manifest.yaml");
278
+ const manifestVersion = (parse(readFileSync(manifestPath, "utf8")) as { version: string }).version;
279
+ const api = createMarketApi(makeEnv());
280
+ g.http = { user: { id: 1 } };
281
+ await api.meta.get();
282
+ expect((last?.data as { apiVersion?: string })?.apiVersion).toBe(manifestVersion);
283
+ });
284
+ });
285
+
286
+ describe("POST install(cdn:Form B 索引解析属市场侧,安装器不拼 URL)", () => {
287
+ it("指定版本:解析 bundles+manifests → runInstall 推进至 done,task 文件全 stage 就位且索引拉取带 no-cache", async () => {
288
+ const api = createMarketApi(makeEnv());
289
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
290
+ await api.install.post();
291
+ expect(last?.status).toBe(200);
292
+ const taskId = (last?.data as { task_id?: unknown })?.task_id;
293
+ expect(typeof taskId).toBe("string");
294
+ // 索引解析:bundles.yaml + manifests.yaml + 分发包,URL 由 base 归一化拼装
295
+ expect(fetchCalls[0]).toBe(`${CDN}bundles.yaml`);
296
+ expect(fetchCalls).toContain(`${CDN}manifests.yaml`);
297
+ expect(fetchCalls).toContain(`${CDN}demo-solution-0.1.0.tgz`);
298
+ // CDN 拉取禁缓存(索引时效性)
299
+ expect((fetchInits[0] as { headers?: Record<string, string> })?.headers?.["cache-control"]).toBe("no-cache");
300
+ // 后台推进:runInstall 状态机逐 stage 落盘,终态 done + 全 stage 明细
301
+ const rec = await getTask(taskDir, taskId as string);
302
+ expect(rec?.status).toBe("done");
303
+ expect(rec?.stages.map(s => s.stage)).toEqual(STAGE_ORDER);
304
+ expect(rec?.version).toBe("0.1.0");
305
+ expect(rec?.appliedSeq).toBe(3);
306
+ });
307
+
308
+ it("version 缺省取 latest(同名只展示最高版本,PRD §5.3)", async () => {
309
+ const api = createMarketApi(makeEnv());
310
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
311
+ await api.install.post();
312
+ expect(last?.status).toBe(200);
313
+ expect(fetchCalls).toContain(`${CDN}demo-solution-0.2.0.tgz`);
314
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
315
+ expect((await getTask(taskDir, taskId))?.version).toBe("0.2.0");
316
+ });
317
+
318
+ it("可读失败不崩:source 非法 400 / 未配置市场地址 503 / 索引无此方案 404", async () => {
319
+ const api = createMarketApi(makeEnv());
320
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "git" } };
321
+ await api.install.post();
322
+ expect(last?.status).toBe(400);
323
+ const unconfigured = createMarketApi(makeEnv({ cdnBase: async () => null }));
324
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
325
+ await unconfigured.install.post();
326
+ expect(last?.status).toBe(503);
327
+ expect(String(last?.msg)).toContain("未配置市场地址");
328
+ g.http = { user: { id: 1 }, body: { name: "nope", version: null, source: "cdn" } };
329
+ await api.install.post();
330
+ expect(last?.status).toBe(404);
331
+ });
332
+ });
333
+
334
+ describe("POST install/cancel(P3-12 端点接线,U5/P3-8 令牌的消费面)", () => {
335
+ it("无进行中安装 → 404(取消只对 running 态有效,任务终态后信号已注销)", async () => {
336
+ const api = createMarketApi(makeEnv());
337
+ g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
338
+ await api.install.cancel.post();
339
+ expect(last?.status).toBe(404);
340
+ });
341
+
342
+ it("进行中(extracting 段门控阻塞)→ cancel 200 置位旗标 → install 终态 failed+cancelled、staging 清场", async () => {
343
+ let release: () => void = () => {};
344
+ const gate = new Promise<void>((r) => {
345
+ release = r;
346
+ });
347
+ const base = bundleExec(); // tar 真写出 §2.9 产物树(extract 复核不误报失败)
348
+ const api = createMarketApi(makeEnv({
349
+ exec: {
350
+ run: async (argv) => {
351
+ if (argv[0] === "tar" && argv[1] === "-xzf")
352
+ await gate; // extracting 段阻塞——模拟长安装窗口,取消旗标在途置位
353
+ return await base.run(argv);
354
+ },
355
+ },
356
+ }));
357
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
358
+ const running = api.install.post(); // 不 await——在途安装
359
+ await vi.waitFor(async () => {
360
+ // 任务文件可见(preflight 段已落盘)即可取消——不必等特定 stage
361
+ expect((await fsp.readdir(taskDir)).filter(f => f.endsWith(".json")).length).toBeGreaterThan(0);
362
+ });
363
+ g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
364
+ await api.install.cancel.post();
365
+ expect(last?.status).toBe(200);
366
+ expect((last?.data as { ok?: boolean })?.ok).toBe(true);
367
+ release(); // 放行 extract → 下一段间探测命中旗标 → h.kill 语义(未进 migrate 即回退)
368
+ await running;
369
+ // 终态断言读最后落盘的任务记录(respond 互斥写 last,此处绕开)
370
+ const id = (await fsp.readdir(taskDir)).find(f => f.endsWith(".json"))!;
371
+ const rec = await getTask(taskDir, id.replace(/\.json$/, ""));
372
+ expect(rec?.status).toBe("failed");
373
+ expect(rec?.cancelled).toBe(true);
374
+ // 清场:staging 回退装前(D11①锁前语义)
375
+ await expect(fsp.access(staging)).rejects.toThrow();
376
+ });
377
+ });
378
+
379
+ describe("POST install(file:上传载荷,upload/api.ts 先例双路径)", () => {
380
+ it("multipart 上传 tgz → 验列解包读 manifest(version 由 manifest 兜底)→ runInstall done", async () => {
381
+ const api = createMarketApi(makeEnv());
382
+ g.http = {
383
+ user: { id: 1 },
384
+ body: { name: "demo-solution", version: null, source: "file" },
385
+ files: [{ name: "demo-solution-0.1.0.tgz", content_type: "application/gzip" }, { name: "demo-solution-0.1.0.tgz.sig", content_type: "application/json" }],
386
+ file: async (i: number) => (i === 0 ? ARCHIVE : SIG_BYTES),
387
+ };
388
+ await api.install.post();
389
+ expect(last?.status).toBe(200);
390
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
391
+ const rec = await getTask(taskDir, taskId);
392
+ expect(rec?.status).toBe("done");
393
+ expect(rec?.version).toBe("0.1.0");
394
+ });
395
+
396
+ it("Form A 包(manifest 无 archive 摘要,ojm build --bundle 产物形态):端点补算 sha256/size 注入 archive → runInstall done(附录 B-10)", async () => {
397
+ const exec: ExecFace = {
398
+ run: async (argv) => {
399
+ if (argv[0] === "tar" && argv[1] === "-tvzf")
400
+ return { code: 0, stdout: "demo-solution-0.1.0/manifest.yaml\ndemo-solution-0.1.0/api/demo-solution-0.1.0/manifest.yaml\ndemo-solution-0.1.0/api/demo-solution-0.1.0/routes.js\ndemo-solution-0.1.0/web/modules/demo-solution/0.1.0/entry.js\n", stderr: "" };
401
+ if (argv[0] === "tar" && argv[1] === "-xzf") {
402
+ // §2.9 单根布局原样解包 + flattenBundleRoot 归平(由 manifestOfUpload 调用)
403
+ const dest = argv[4]!;
404
+ const rootDir = join(dest, "demo-solution-0.1.0");
405
+ await fsp.mkdir(join(rootDir, "api", "demo-solution-0.1.0"), { recursive: true });
406
+ await fsp.mkdir(join(rootDir, "web", "modules", "demo-solution", "0.1.0"), { recursive: true });
407
+ const { archive: _archive, ...formA } = manifestFixture("0.1.0");
408
+ await fsp.writeFile(join(rootDir, "manifest.yaml"), JSON.stringify(formA));
409
+ // oj 原生清单(api 模块目录内,desc === summary,C2-31 消费面)
410
+ await fsp.writeFile(join(rootDir, "api", "demo-solution-0.1.0", "manifest.yaml"), JSON.stringify({ name: "demo-solution", desc: "演示方案", version: "0.1.0" }));
411
+ await fsp.writeFile(join(rootDir, "api", "demo-solution-0.1.0", "routes.js"), "export {};\n");
412
+ await fsp.writeFile(join(rootDir, "web", "modules", "demo-solution", "0.1.0", "entry.js"), ENTRY);
413
+ await fsp.writeFile(join(rootDir, "web", "modules", "demo-solution", "0.1.0", "main.css"), "/* demo */");
414
+ return { code: 0, stdout: "", stderr: "" };
415
+ }
416
+ if (argv[0] === "oj" && argv[1] === "migrate")
417
+ return { code: 0, stdout: `${MIGRATE_LINE}\n`, stderr: "" };
418
+ return { code: 0, stdout: "", stderr: "" };
419
+ },
420
+ };
421
+ const api = createMarketApi(makeEnv({ exec }));
422
+ g.http = {
423
+ user: { id: 1 },
424
+ body: { name: "demo-solution", version: null, source: "file" },
425
+ files: [{ name: "demo-solution-0.1.0.tgz", content_type: "application/gzip" }, { name: "demo-solution-0.1.0.tgz.sig", content_type: "application/json" }],
426
+ file: async (i: number) => (i === 0 ? ARCHIVE : SIG_BYTES),
427
+ };
428
+ await api.install.post();
429
+ expect(last?.status).toBe(200);
430
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
431
+ const rec = await getTask(taskDir, taskId);
432
+ // verify 段对 injected sha256 fail-closed 复核同批字节(R10 语义保持)→ done
433
+ expect(rec?.status).toBe("done");
434
+ expect(rec?.stages.map(s => s.stage)).toEqual(STAGE_ORDER);
435
+ // 单根归平 + B-11 发布面:lockCommit rename 后版本目录 = api 模块目录(原生清单在根),
436
+ // web 树经 deploying_web 段从 staging 复制进站点 <dist>/modules
437
+ const versionDir = join(root, "modules", "demo-solution-0.1.0");
438
+ expect(await fsp.readFile(join(versionDir, "manifest.yaml"), "utf8")).toContain("demo-solution");
439
+ expect(await fsp.readdir(join(versionDir))).toEqual(["manifest.yaml", "routes.js"]);
440
+ expect(await fsp.readdir(join(siteModulesDir, "demo-solution", "0.1.0"))).toEqual(["entry.js", "main.css"]);
441
+ });
442
+
443
+ it("缺文件 → 400 可读失败", async () => {
444
+ const api = createMarketApi(makeEnv());
445
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "file" } };
446
+ await api.install.post();
447
+ expect(last?.status).toBe(400);
448
+ });
449
+
450
+ it("file 源缺 .sig 侧车 → runInstall 终态 failed@verifying code=sig_missing(G4 硬 fail-closed,无逃生门)", async () => {
451
+ const api = createMarketApi(makeEnv());
452
+ g.http = {
453
+ user: { id: 1 },
454
+ body: { name: "demo-solution", version: null, source: "file" },
455
+ files: [{ name: "demo-solution-0.1.0.tgz", content_type: "application/gzip" }],
456
+ file: async () => ARCHIVE,
457
+ };
458
+ await api.install.post();
459
+ expect(last?.status).toBe(200); // 编排返回任务 id(失败在任务终态,人话在任务记录)
460
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
461
+ const rec = await getTask(taskDir, taskId);
462
+ expect(rec?.status).toBe("failed");
463
+ expect(rec?.stage).toBe("verifying");
464
+ expect(rec?.error?.hintKey).toBe("lifecycle.stage.verifying.sig_missing");
465
+ expect(rec?.stages.find(s => s.stage === "verifying")?.message).toContain("sig_missing");
466
+ // 锁前清场(失败语义①):staging 删、锁/库表零改
467
+ await expect(fsp.access(staging)).rejects.toThrow();
468
+ await expect(fsp.access(join(sharedRoot, "manifests.yaml"))).rejects.toThrow();
469
+ });
470
+
471
+ it(".sig 非 JSON → 400 可读失败(不进安装器)", async () => {
472
+ const api = createMarketApi(makeEnv());
473
+ g.http = {
474
+ user: { id: 1 },
475
+ body: { name: "demo-solution", version: null, source: "file" },
476
+ files: [{ name: "demo-solution-0.1.0.tgz", content_type: "application/gzip" }, { name: "x.sig", content_type: "application/json" }],
477
+ file: async (i: number) => (i === 0 ? ARCHIVE : new TextEncoder().encode("not json")),
478
+ };
479
+ await api.install.post();
480
+ expect(last?.status).toBe(400);
481
+ expect(String(last?.msg)).toContain("不是合法 JSON");
482
+ });
483
+ });
484
+
485
+ describe("POST /market/trust {task_id}(P2-3 TOFU 确认:混淆代理防线)", () => {
486
+ async function seedNeedsTrust(keyId: string, publicKey: string, fingerprint?: string): Promise<string> {
487
+ const id = `install-${Date.now()}-confirm`;
488
+ await writeTask(taskDir, {
489
+ id,
490
+ name: "demo-solution",
491
+ version: "0.1.0",
492
+ status: "needs_trust",
493
+ stage: "verifying",
494
+ progress: 30,
495
+ message: "待信任确认",
496
+ stages: [],
497
+ trust: { key_id: keyId, public_key: publicKey, fingerprint: fingerprint ?? createHash("sha256").update(hexToBytes(publicKey)).digest("hex") },
498
+ startedAt: Date.now(),
499
+ finishedAt: Date.now(),
500
+ });
501
+ return id;
502
+ }
503
+
504
+ it("确认成功:任务 needs_trust → 指纹复算一致 → 入信任库 added", async () => {
505
+ const api = createMarketApi(makeEnv());
506
+ const { generateKeyPair } = await import("../../../../src/lifecycle/keygen");
507
+ const kp = generateKeyPair();
508
+ const id = await seedNeedsTrust("stranger.pub", kp.publicKeyHex);
509
+ g.http = { user: { id: 1 }, body: { task_id: id } };
510
+ await api.trust.post();
511
+ expect(last?.status).toBe(200);
512
+ expect(last?.data).toMatchObject({ result: "added", key_id: "stranger.pub" });
513
+ // 信任库真落库
514
+ const keys = await createTrustStore(join(sharedRoot, ".solutions", "trusted-keys.yaml")).list();
515
+ expect(keys.map(k => k.key_id)).toContain("stranger.pub");
516
+ // 审计留痕(operator=admin,fire-and-forget 等一拍)
517
+ await new Promise(r => setTimeout(r, 20));
518
+ const oplog = dbo.prepare("select action, target from sys_oplog where action = 'solution.trust.add'").all();
519
+ expect(oplog).toHaveLength(1);
520
+ });
521
+
522
+ it("门与形态:非 superadmin 403;缺 task_id 400;任务不存在 404;非 needs_trust 409", async () => {
523
+ const api = createMarketApi(makeEnv());
524
+ g.http = { user: { id: 2 }, body: { task_id: "x" } };
525
+ await api.trust.post();
526
+ expect(last?.status).toBe(403);
527
+ g.http = { user: { id: 1 }, body: {} };
528
+ await api.trust.post();
529
+ expect(last?.status).toBe(400);
530
+ g.http = { user: { id: 1 }, body: { task_id: "install-1-nope" } };
531
+ await api.trust.post();
532
+ expect(last?.status).toBe(404);
533
+ // done 任务无需确认
534
+ await writeTask(taskDir, { id: "done-1", name: "d", version: "1.0.0", status: "done", stage: "done", progress: 100, message: "x", stages: [], startedAt: 1, finishedAt: 2 });
535
+ g.http = { user: { id: 1 }, body: { task_id: "done-1" } };
536
+ await api.trust.post();
537
+ expect(last?.status).toBe(409);
538
+ });
539
+
540
+ it("混淆代理:任务记录指纹与公钥不符 → 409 拒,信任库零变化", async () => {
541
+ const api = createMarketApi(makeEnv());
542
+ const { generateKeyPair } = await import("../../../../src/lifecycle/keygen");
543
+ const kp = generateKeyPair();
544
+ const id = await seedNeedsTrust("stranger.pub", kp.publicKeyHex, "f".repeat(64)); // 指纹造假
545
+ g.http = { user: { id: 1 }, body: { task_id: id } };
546
+ await api.trust.post();
547
+ expect(last?.status).toBe(409);
548
+ expect(String(last?.msg)).toContain("篡改");
549
+ const keys = await createTrustStore(join(sharedRoot, ".solutions", "trusted-keys.yaml")).list();
550
+ expect(keys.map(k => k.key_id)).not.toContain("stranger.pub");
551
+ });
552
+
553
+ it("撞绑定:key_id 已绑其他公钥 → 409 trust_bound_mismatch,库内不动", async () => {
554
+ const api = createMarketApi(makeEnv());
555
+ const { generateKeyPair } = await import("../../../../src/lifecycle/keygen");
556
+ const kp = generateKeyPair();
557
+ const id = await seedNeedsTrust("test.publisher", kp.publicKeyHex); // 已播种的 key_id
558
+ g.http = { user: { id: 1 }, body: { task_id: id } };
559
+ await api.trust.post();
560
+ expect(last?.status).toBe(409);
561
+ expect(String(last?.msg)).toContain("首次绑定不可改");
562
+ });
563
+ });
564
+
565
+ describe("GET task(§10.2 轮询消费面)", () => {
566
+ it("脱敏断言:任务内容写侧已 redact,读侧无秘密可泄;缺 id 400 / 未知 id 404", async () => {
567
+ await writeTask(taskDir, {
568
+ id: "leaky",
569
+ name: "demo-solution",
570
+ version: "0.1.0",
571
+ status: "failed",
572
+ stage: "downloading",
573
+ progress: 10,
574
+ message: "下载失败:https://cdn.example.com/a.tgz?token=secret123",
575
+ stages: [{ stage: "downloading", ok: false, message: "凭证 Bearer abc123xyz 泄漏" }],
576
+ startedAt: 1,
577
+ finishedAt: 2,
578
+ error: { reason: "参数 password=hunter2 泄漏", retryable: true },
579
+ });
580
+ const api = createMarketApi(makeEnv());
581
+ g.http = { user: { id: 1 }, query: { id: "leaky" } };
582
+ await api.task.get();
583
+ expect(last?.status).toBe(200);
584
+ const raw = JSON.stringify(last?.data);
585
+ expect(raw).not.toContain("secret123");
586
+ expect(raw).not.toContain("abc123xyz");
587
+ expect(raw).not.toContain("hunter2");
588
+ g.http = { user: { id: 1 }, query: {} };
589
+ await api.task.get();
590
+ expect(last?.status).toBe(400);
591
+ g.http = { user: { id: 1 }, query: { id: "nope" } };
592
+ await api.task.get();
593
+ expect(last?.status).toBe(404);
594
+ });
595
+ });
596
+
597
+ describe("POST enable(生命周期开关:P1-13 瘦客户端触发面,四处同写归 setEnabled,D12/AC-P1-6)", () => {
598
+ /** 预置「已装」形态:锁两条目(单条目禁用会触发空锁拒启,S1-1.2)+ sidecar + route 行 + modules.json 条目 + 版本目录 */
599
+ async function seedInstalled(): Promise<void> {
600
+ await writeLock(join(sharedRoot, "manifests.yaml"), { "demo-solution": "0.1.0", "other": "1.0.0" });
601
+ const state = await createStateStore(join(sharedRoot, ".solutions", "state.yaml"));
602
+ await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1 } } });
603
+ await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
604
+ await fsp.mkdir(dirname(modulesJsonPath), { recursive: true });
605
+ await fsp.writeFile(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", enabled: true, src: "modules/demo-solution/" }])}\n`);
606
+ dbo.prepare("INSERT INTO sys_solution_route (path, module, category) VALUES (?, ?, ?)").run("/demo-solution", "demo-solution", "main");
607
+ }
608
+
609
+ it("禁用再启用:锁摘除/恢复 + sidecar + sys_solution_route + modules.json 四处同写", async () => {
610
+ await seedInstalled();
611
+ const api = createMarketApi(makeEnv());
612
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", enabled: false } };
613
+ await api.enable.post();
614
+ expect(last?.status).toBe(200);
615
+ expect(last?.data).toMatchObject({ ok: true });
616
+ // ① 锁摘除(other 条目保留——空锁拒启防线未被波及)
617
+ expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ other: "1.0.0" });
618
+ // ② sidecar enabled=false,version 保留(启用恢复依据)
619
+ const state = await createStateStore(join(sharedRoot, ".solutions", "state.yaml"));
620
+ expect(state.read().solutions["demo-solution"]).toMatchObject({ version: "0.1.0", enabled: false });
621
+ // ③ route enabled=0
622
+ expect(dbo.prepare("SELECT enabled FROM sys_solution_route WHERE module = ?").get("demo-solution")).toMatchObject({ enabled: 0 });
623
+ // ④ modules.json enabled=false
624
+ const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as Array<{ name: string, enabled: boolean }>;
625
+ expect(list[0]).toMatchObject({ name: "demo-solution", enabled: false });
626
+ // 启用:从 sidecar 记录恢复锁条目
627
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", enabled: true } };
628
+ await api.enable.post();
629
+ expect(last?.status).toBe(200);
630
+ expect(last?.data).toMatchObject({ ok: true });
631
+ expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ "demo-solution": "0.1.0", "other": "1.0.0" });
632
+ });
633
+
634
+ it("门禁与可读失败:非 superadmin 403;name 缺失/enabled 非布尔 400;未装方案 ok:false 不崩", async () => {
635
+ await seedInstalled();
636
+ const api = createMarketApi(makeEnv());
637
+ g.http = { user: { id: 2 }, body: { name: "demo-solution", enabled: false } };
638
+ await api.enable.post();
639
+ expect(last?.status).toBe(403);
640
+ g.http = { user: { id: 1 }, body: { enabled: false } };
641
+ await api.enable.post();
642
+ expect(last?.status).toBe(400);
643
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", enabled: "yes" } };
644
+ await api.enable.post();
645
+ expect(last?.status).toBe(400);
646
+ g.http = { user: { id: 1 }, body: { name: "nope", enabled: false } };
647
+ await api.enable.post();
648
+ expect(last?.status).toBe(200);
649
+ expect(last?.data).toMatchObject({ ok: false });
650
+ });
651
+ });
652
+
653
+ describe("GET index(CDN bundles.yaml no-cache 拉取 + 本地 state 对比)", () => {
654
+ it("未配置市场地址 → 503 可读错误(「未配置市场地址」卡片区口径,PRD §5.3)", async () => {
655
+ const api = createMarketApi(makeEnv({ cdnBase: async () => null }));
656
+ await api.index.get();
657
+ expect(last?.status).toBe(503);
658
+ expect(String(last?.msg)).toContain("未配置市场地址");
659
+ });
660
+
661
+ it("聚合对比:CDN 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/trust/key_id/operator(P2-11);本地独有方案也列出;拉取带 no-cache", async () => {
662
+ const env = makeEnv();
663
+ const state = await createStateStore(env.statePath);
664
+ await state.write({
665
+ solutions: {
666
+ "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1, trust_state: "trusted", archive: { sha256: "a", key_id: "test.publisher", signature: {} } },
667
+ "legacy": { version: "9.9.9", enabled: false, installedAt: 2 },
668
+ },
669
+ });
670
+ const api = createMarketApi(env);
671
+ await api.index.get();
672
+ expect(last?.status).toBe(200);
673
+ expect(fetchCalls[0]).toBe(`${CDN}bundles.yaml`);
674
+ expect((fetchInits[0] as { headers?: Record<string, string> })?.headers?.["cache-control"]).toBe("no-cache");
675
+ expect(last?.data).toEqual([
676
+ { name: "demo-solution", latest: "0.2.0", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.2.0", "0.1.0"], changelog: ["0.2.0 变更条目"], publisher: "oj-module", trust_state: "trusted", key_id: "test.publisher", operator: null },
677
+ { name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, trust_state: null, key_id: null, operator: null },
678
+ { name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, trust_state: null, key_id: null, operator: null },
679
+ ]);
680
+ });
681
+
682
+ it("operator 回落 history 行(P2-11:最近一次 install 动作的 operator;表缺失/无行 → null 不炸)", async () => {
683
+ dbo.exec(MARKET_HISTORY_DDL);
684
+ dbo.prepare("insert into market_history (solution, version, action, status, operator, message, created_at) values (?, ?, ?, ?, ?, ?, ?)")
685
+ .run("demo-solution", "0.1.0", "install", "done", "alice", "", 1700000000);
686
+ const api = createMarketApi(makeEnv());
687
+ g.http = { user: { id: 1 } };
688
+ await api.index.get();
689
+ expect(last?.status).toBe(200);
690
+ const rows = last?.data as Array<{ name: string, operator: string | null }>;
691
+ expect(rows.find(r => r.name === "demo-solution")?.operator).toBe("alice");
692
+ expect(rows.find(r => r.name === "wiki")?.operator).toBeNull();
693
+ });
694
+
695
+ it("manifests.yaml 不可达 → changelog/publisher 降级 null,索引主体照常 200(可用性优先)", async () => {
696
+ const fetchFn = (async (url: string) => {
697
+ if (String(url).endsWith("manifests.yaml"))
698
+ return { ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) };
699
+ return cdnFetch(url);
700
+ }) as FetchFace;
701
+ const api = createMarketApi(makeEnv({ fetch: fetchFn }));
702
+ g.http = { user: { id: 1 } };
703
+ await api.index.get();
704
+ expect(last?.status).toBe(200);
705
+ const rows = last?.data as Array<{ name: string, changelog: unknown, publisher: string | null }>;
706
+ const demo = rows.find(r => r.name === "demo-solution");
707
+ expect(demo?.changelog).toBeNull();
708
+ expect(demo?.publisher).toBeNull();
709
+ expect(demo?.latest).toBe("0.2.0");
710
+ });
711
+
712
+ it("P2-12 bootstrap key(G3 双轨第一轨):信任库为空 + bootstrapKey 注入 → cdn 首装直达 done 不落 needs_trust", async () => {
713
+ await fsp.rm(join(sharedRoot, ".solutions", "trusted-keys.yaml"), { force: true }); // beforeEach 已播种,删库文件模拟全新站点
714
+ const env = makeEnv({ bootstrapKey: { key_id: "test.publisher", public_key: TEST_KEY.publicKeyHex } });
715
+ const api = createMarketApi(env);
716
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
717
+ await api.install.post();
718
+ expect(last?.status).toBe(200);
719
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
720
+ const rec = await getTask(taskDir, taskId);
721
+ expect(rec?.status).toBe("done"); // 免 TOFU 直接过验签
722
+ // 播种落库可读
723
+ expect(await createTrustStore(join(sharedRoot, ".solutions", "trusted-keys.yaml")).get("test.publisher")).toBe(TEST_KEY.publicKeyHex);
724
+ });
725
+
726
+ it("P2-12:无 bootstrapKey(内置未回填口径)+ 信任库为空 → needs_trust(双轨第二轨 TOFU 不失守)", async () => {
727
+ await fsp.rm(join(sharedRoot, ".solutions", "trusted-keys.yaml"), { force: true });
728
+ const api = createMarketApi(makeEnv());
729
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
730
+ await api.install.post();
731
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
732
+ expect((await getTask(taskDir, taskId))?.status).toBe("needs_trust");
733
+ });
734
+
735
+ it("archive.file 邪例表(P2-11 索引投毒加固:file 逐字 <name>-<version>.tgz)→ 502 拒", async () => {
736
+ for (const file of ["../evil.tgz", "/etc/hosts.tgz", "other-0.1.0.tgz", "demo-solution-9.9.9.tgz"]) {
737
+ const bundles = JSON.stringify({ solutions: [{ name: "demo-solution", latest: "0.1.0", versions: [{ version: "0.1.0", file, sha256: SHA256, size: ARCHIVE.byteLength }] }] });
738
+ const manifests = JSON.stringify([manifestFixture("0.1.0")]);
739
+ const fetchFn = (async (url: string) => {
740
+ const text = String(url).endsWith("bundles.yaml") ? bundles : String(url).endsWith("manifests.yaml") ? manifests : null;
741
+ if (text !== null)
742
+ return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
743
+ if (String(url).endsWith(".yaml.sig"))
744
+ return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }; // 缺失 .sig → 容忍(本用例聚焦 file 逐字校验,签名另测)
745
+ return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer }; // .tgz 下载字节
746
+ }) as FetchFace;
747
+ const api = createMarketApi(makeEnv({ fetch: fetchFn }));
748
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
749
+ await api.install.post();
750
+ expect(last?.status, `file=${file}`).toBe(502);
751
+ expect(String(last?.msg), `file=${file}`).toContain("逐字");
752
+ }
753
+ });
754
+
755
+ it("索引不可达 → 502 可读错误不崩", async () => {
756
+ const api = createMarketApi(makeEnv({
757
+ fetch: (async () => ({ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) })) as FetchFace,
758
+ }));
759
+ await api.index.get();
760
+ expect(last?.status).toBe(502);
761
+ });
762
+ });
763
+
764
+ describe("P3-7 索引签名校验(G4 fail-closed:署名相符采用 / 篡改拒 / 缺失容忍)", () => {
765
+ /** 注入 .sig 的 CDN:非空 .sig 进入验签分支;缺/空 .sig → 404(被缺失容忍 catch 吞) */
766
+ function signingCdnFetch(sigs: { bundles?: string, manifests?: string }): FetchFace {
767
+ return ((url: string, init?: unknown) => {
768
+ fetchCalls.push(url);
769
+ fetchInits.push(init);
770
+ if (String(url).endsWith(".yaml.sig")) {
771
+ const kind = String(url).includes("manifests") ? "manifests" : "bundles";
772
+ const body = sigs[kind];
773
+ if (body !== undefined && body.trim() !== "")
774
+ return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(body).buffer as ArrayBuffer };
775
+ return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; // 缺失/空 .sig
776
+ }
777
+ const text = String(url).endsWith("bundles.yaml")
778
+ ? BUNDLES
779
+ : String(url).endsWith("manifests.yaml") ? MANIFESTS : null;
780
+ if (text !== null)
781
+ return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
782
+ if (String(url).endsWith(".tgz"))
783
+ return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
784
+ return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
785
+ }) as FetchFace;
786
+ }
787
+
788
+ it("署名相符:bundles/manifests 均带合法 .sig → index 照常 200(G4 采用,验签分支确已触发)", async () => {
789
+ const bundlesSig = JSON.stringify(signIndex("bundles", BUNDLES, TEST_KEY.privateKeyPem, "test.publisher"));
790
+ const manifestsSig = JSON.stringify(signIndex("manifests", MANIFESTS, TEST_KEY.privateKeyPem, "test.publisher"));
791
+ const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ bundles: bundlesSig, manifests: manifestsSig }) }));
792
+ g.http = { user: { id: 1 } };
793
+ await api.index.get();
794
+ expect(last?.status).toBe(200);
795
+ expect((last?.data as unknown[]).length).toBeGreaterThan(0);
796
+ expect(fetchCalls.some(u => u.endsWith("bundles.yaml.sig"))).toBe(true);
797
+ expect(fetchCalls.some(u => u.endsWith("manifests.yaml.sig"))).toBe(true);
798
+ });
799
+
800
+ it("篡改拒:.sig 对别的字节签名(非索引真值)→ verifyIndexSig 不符 → 502 fail-closed(G4 不吞)", async () => {
801
+ const bundlesSig = JSON.stringify(signIndex("bundles", `${BUNDLES}\n# tampered`, TEST_KEY.privateKeyPem, "test.publisher"));
802
+ const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ bundles: bundlesSig }) }));
803
+ g.http = { user: { id: 1 } };
804
+ await api.index.get();
805
+ expect(last?.status).toBe(502);
806
+ expect(String(last?.msg)).toContain("签名校验失败");
807
+ });
808
+
809
+ it("缺失容忍:CDN 不回 .sig(基线 cdnFetch 口径)→ index 仍 200(滚动发布期缺口,不阻断加载)", async () => {
810
+ const api = createMarketApi(makeEnv({ fetch: cdnFetch }));
811
+ g.http = { user: { id: 1 } };
812
+ await api.index.get();
813
+ expect(last?.status).toBe(200);
814
+ expect((last?.data as unknown[]).length).toBeGreaterThan(0);
815
+ });
816
+ });
817
+
818
+ describe("hostVersions 缺省接线(B-11:C2-09 比对真源 = <dist>/versions.json,ojm 宿主构建产物)", () => {
819
+ it("versionsJsonPathOf 布局推导:modules.json 同目录 versions.json(相对/绝对同构)", () => {
820
+ expect(versionsJsonPathOf("dist/modules.json")).toBe("dist/versions.json");
821
+ expect(versionsJsonPathOf("/site/dist/modules.json")).toBe("/site/dist/versions.json");
822
+ expect(versionsJsonPathOf("modules.json")).toBe("versions.json");
823
+ });
824
+
825
+ it("parseVersionsJson:{packageName: version} 全字符串表 → 解析;任何违约(非对象/值非字符串/坏 JSON)→ undefined(fail-closed 不猜)", () => {
826
+ expect(parseVersionsJson(JSON.stringify({ "@oj-module/runtime": "0.1.77", "react": "19.0.0" })))
827
+ .toEqual({ "@oj-module/runtime": "0.1.77", "react": "19.0.0" });
828
+ for (const bad of ["not-json", "[]", "42", JSON.stringify({ react: 19 }), JSON.stringify({ a: null })]) {
829
+ expect(parseVersionsJson(bad)).toBeUndefined();
830
+ }
831
+ });
832
+
833
+ it("集成:env 未注入 hostVersions + dist/versions.json 在盘 → install 全链 done(C2-09 比对走缺省真源)", async () => {
834
+ await fsp.mkdir(dirname(modulesJsonPath), { recursive: true });
835
+ await fsp.writeFile(join(dirname(modulesJsonPath), "versions.json"), JSON.stringify({ "@oj-module/runtime": "0.1.77" }));
836
+ const env = makeEnv();
837
+ delete (env as Partial<MarketEnv>).hostVersions;
838
+ const api = createMarketApi(env);
839
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
840
+ await api.install.post();
841
+ expect(last?.status).toBe(200);
842
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
843
+ expect((await getTask(taskDir, taskId))?.status).toBe("done");
844
+ });
845
+
846
+ it("集成:versions.json 缺失且未注入 → C2-09 fail-closed,install 终态 failed@preflight", async () => {
847
+ const env = makeEnv();
848
+ delete (env as Partial<MarketEnv>).hostVersions;
849
+ const api = createMarketApi(env);
850
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
851
+ await api.install.post();
852
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
853
+ const rec = await getTask(taskDir, taskId);
854
+ expect(rec?.status).toBe("failed");
855
+ expect(rec?.stage).toBe("preflight");
856
+ expect(rec?.stages[0]?.message).toContain("C2-09");
857
+ });
858
+ });
859
+
860
+ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator 通道 / 账本真源 / index 即对账入口", () => {
861
+ it("marketEnv() 缺省 = oj serve release 布局:锁与版本目录在 serve 根 dist(B3),versions.json 同根推导(B4);暂存/任务/sidecar 留根外", () => {
862
+ const env = marketEnv();
863
+ expect(env.sharedRoot).toBe("dist");
864
+ expect(env.siteModulesDir).toBe("dist/modules");
865
+ expect(env.modulesJsonPath).toBe("dist/modules.json");
866
+ expect(versionsJsonPathOf(env.modulesJsonPath)).toBe("dist/versions.json");
867
+ expect(env.staging).toBe("modules/.staging");
868
+ expect(env.taskDir).toBe("modules/.install-tasks");
869
+ expect(env.statePath).toBe("modules/.solutions/state.yaml");
870
+ expect(env.dbPath).toBe("db.sqlite");
871
+ expect(env.apiPrefix).toBe("/api");
872
+ });
873
+
874
+ it("平台 exec 包装转发 opts:migrate 段收到 {timeoutMs: 600000}(oj 0.1.78 缺省 30s 会杀长迁移,P1-13c 遗嘱)", async () => {
875
+ const seen: Array<{ argv: string[], opts?: { timeoutMs?: number } }> = [];
876
+ const base = bundleExec();
877
+ const exec: ExecFace = {
878
+ run: (argv, opts) => {
879
+ seen.push({ argv, opts });
880
+ return base.run(argv, opts);
881
+ },
882
+ };
883
+ const api = createMarketApi(makeEnv({ exec }));
884
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
885
+ await api.install.post();
886
+ expect(last?.status).toBe(200);
887
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
888
+ expect((await getTask(taskDir, taskId))?.status).toBe("done");
889
+ const mig = seen.find(c => c.argv[0] === "oj" && c.argv[1] === "migrate");
890
+ expect(mig).toBeDefined();
891
+ expect(mig?.opts).toEqual({ timeoutMs: 600000 });
892
+ });
893
+
894
+ it("smoke 探针同源补全:http.headers.host 在场时相对探针 URL 按请求源补全为绝对 URL(isolate fetch = deno_fetch 无同源 base,相对恒 TypeError——P1-16 接线④)", async () => {
895
+ const api = createMarketApi(makeEnv({}));
896
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" }, headers: { host: "site.example:9781" } };
897
+ await api.install.post();
898
+ expect(last?.status).toBe(200);
899
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
900
+ expect((await getTask(taskDir, taskId))?.status).toBe("done");
901
+ // 夹具 manifest 无 health → 探首个非匿名路由 /demo-solution → env.apiPrefix=/api/demo-solution
902
+ // → 相对探针 /api/demo-solution/demo-solution 补全为请求源绝对 URL
903
+ expect(fetchCalls).toContain("http://site.example:9781/api/demo-solution/demo-solution");
904
+ // CDN 拉取(绝对 URL)不受补全影响
905
+ expect(fetchCalls.some(u => u.startsWith("https://cdn.example.com/"))).toBe(true);
906
+ });
907
+
908
+ it("ledgerHead 接真源:env.ledgerHeadOf 注入时 migrate 段按其值判禁降版(head 高于目标 seq → 拒切 failed@migrating)", async () => {
909
+ const api = createMarketApi(makeEnv({ ledgerHeadOf: async () => 99 }));
910
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
911
+ await api.install.post();
912
+ const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
913
+ const rec = await getTask(taskDir, taskId);
914
+ expect(rec?.status).toBe("failed");
915
+ expect(rec?.stage).toBe("migrating");
916
+ expect(rec?.stages.find(s => s.stage === "migrating")?.message).toContain("禁降版");
917
+ });
918
+
919
+ it("operator 通道:install/enable 端点按 http.user.id 反查 username 落 market_history(uninstall 同款反查)", async () => {
920
+ dbo.exec(MARKET_HISTORY_DDL);
921
+ // 预置「已装」最小形态(enable 用例同款:单条目禁用会触发空锁拒启,S1-1.2)
922
+ await writeLock(join(sharedRoot, "manifests.yaml"), { "demo-solution": "0.1.0", "other": "1.0.0" });
923
+ const state = await createStateStore(join(sharedRoot, ".solutions", "state.yaml"));
924
+ await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1 } } });
925
+ const api = createMarketApi(makeEnv());
926
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
927
+ await api.install.post();
928
+ expect(last?.status).toBe(200);
929
+ g.http = { user: { id: 1 }, body: { name: "demo-solution", enabled: false } };
930
+ await api.enable.post();
931
+ expect(last?.data).toMatchObject({ ok: true });
932
+ const rows = dbo.prepare("SELECT action, operator FROM market_history ORDER BY rowid").all() as Array<{ action: string, operator: string | null }>;
933
+ expect(rows.find(r => r.action === "install")).toMatchObject({ operator: "admin" });
934
+ expect(rows.find(r => r.action === "disable")).toMatchObject({ operator: "admin" });
935
+ });
936
+
937
+ it("index 即对账入口(P1-10 接线③):M004 分歧(锁指新版、账本 head 落后)经 index 刷新自愈回指旧版", async () => {
938
+ await writeLock(join(sharedRoot, "manifests.yaml"), { "demo-solution": "0.2.0" });
939
+ await fsp.writeFile(`${join(sharedRoot, "manifests.yaml")}.bak`, "demo-solution: 0.1.0\n");
940
+ await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
941
+ await fsp.mkdir(join(sharedRoot, "demo-solution-0.2.0"), { recursive: true });
942
+ // oj journal 真源形态:_oj_migrations (module, version, ...);head=3 < 任务记录 appliedSeq=5
943
+ dbo.exec("CREATE TABLE _oj_migrations (module TEXT NOT NULL, version INTEGER NOT NULL, name TEXT, applied_on TEXT, checksum TEXT, PRIMARY KEY (module, version))");
944
+ dbo.prepare("INSERT INTO _oj_migrations (module, version) VALUES (?, ?)").run("demo-solution", 3);
945
+ await writeTask(taskDir, { id: "t1", name: "demo-solution", version: "0.2.0", status: "done", stage: "done", progress: 100, message: "", stages: [], startedAt: 1, appliedSeq: 5 });
946
+ const api = createMarketApi(makeEnv());
947
+ await api.index.get();
948
+ expect(last?.status).toBe(200);
949
+ expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ "demo-solution": "0.1.0" });
950
+ });
951
+
952
+ it("对账自愈失败降级静默(禁 console——release isolate console 抛 BadResource 会把市场页打 500,Finding A):index 仍 200", async () => {
953
+ // 锁有条目保证 reconcile 真走到 ledgerHeadOf;注入恒拒 → reconcile 拒约 → reconcileOf 降级
954
+ await writeLock(join(sharedRoot, "manifests.yaml"), { "demo-solution": "0.1.0" });
955
+ await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
956
+ const api = createMarketApi(makeEnv({
957
+ ledgerHeadOf: async () => {
958
+ throw new Error("journal 查询炸了");
959
+ },
960
+ }));
961
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
962
+ try {
963
+ await api.index.get();
964
+ expect(last?.status).toBe(200);
965
+ expect(warn).not.toHaveBeenCalled();
966
+ }
967
+ finally {
968
+ warn.mockRestore();
969
+ }
970
+ });
971
+ });