@oj-module/cli 0.2.1 → 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 +46 -7
  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 +162 -0
  223. package/templates/web/src/system/__client/api.ts +382 -0
  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,177 @@
1
+ // BDD:config.yaml 文本锚点编辑(R18,P1-3)。安装器改写 config.yaml 的唯一手段。
2
+ // 约束:禁止 yaml 解析/stringify 回写——用户注释、缩进、空行、键序必须逐字保留。
3
+ // 锚点为整行注释(如 `# ojm:mount`);插入位置=锚点行下一行,缩进继承锚点行;
4
+ // 锚点已存在=原样返回(幂等);锚点缺失=可读错误(message 含锚点名)。
5
+ import * as fsp from "node:fs/promises";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { appendAnonymousPaths, bumpInstallSeq, ensureReloadApi, hasAnchor, insertAnchor } from "../../src/_platform/_lifecycle/text-edit";
9
+
10
+ describe("insertAnchor(R18)", () => {
11
+ it("带注释的 config.yaml 插入后注释/缩进/空行/键序逐字保留", () => {
12
+ const content = [
13
+ "# 用户全局注释,必须保留",
14
+ "server:",
15
+ " port: 8080",
16
+ "",
17
+ " # ojm:reload",
18
+ "database:",
19
+ " host: localhost",
20
+ ].join("\n");
21
+ const result = insertAnchor(content, "# ojm:reload", "reload_api: true");
22
+ expect(result).toBe([
23
+ "# 用户全局注释,必须保留",
24
+ "server:",
25
+ " port: 8080",
26
+ "",
27
+ " # ojm:reload",
28
+ " reload_api: true",
29
+ "database:",
30
+ " host: localhost",
31
+ ].join("\n"));
32
+ });
33
+
34
+ it("锚点后已紧跟同内容块时原样返回(幂等)", () => {
35
+ const content = "server:\n # ojm:mount\n mount: []";
36
+ expect(insertAnchor(content, "# ojm:mount", "mount: []")).toBe(content);
37
+ });
38
+
39
+ it("连续两次调用同一锚点,第二次为 no-op", () => {
40
+ const content = "server:\n # ojm:mount";
41
+ const once = insertAnchor(content, "# ojm:mount", "mount: []");
42
+ const twice = insertAnchor(once, "# ojm:mount", "mount: []");
43
+ expect(twice).toBe(once);
44
+ });
45
+
46
+ it("锚点注释行必须整行匹配:子串提及行不算锚点,插入定位到真实锚点行", () => {
47
+ const content = "# 参考 ojm:mount 文档\nserver:\n # ojm:mount";
48
+ const result = insertAnchor(content, "# ojm:mount", "mount: []");
49
+ expect(result).toBe("# 参考 ojm:mount 文档\nserver:\n # ojm:mount\n mount: []");
50
+ });
51
+
52
+ it("只有相似锚点行(前缀超集)而无真实锚点时报错", () => {
53
+ expect(() => insertAnchor("# ojm:mounts\nserver: {}", "# ojm:mount", "mount: []"))
54
+ .toThrowError(/# ojm:mount/);
55
+ });
56
+
57
+ it("content 无锚点时报错,message 含锚点名", () => {
58
+ expect(() => insertAnchor("server:\n port: 8080", "# ojm:mount", "mount: []"))
59
+ .toThrowError(/# ojm:mount/);
60
+ });
61
+
62
+ it("锚点在末行且无尾随换行时正确换行插入", () => {
63
+ const result = insertAnchor("# ojm:mount", "# ojm:mount", "mount: []");
64
+ expect(result).toBe("# ojm:mount\nmount: []");
65
+ });
66
+ });
67
+
68
+ describe("hasAnchor(R18)", () => {
69
+ it("锚点行存在返回 true", () => {
70
+ expect(hasAnchor("a: 1\n # ojm:mount\nb: 2", "# ojm:mount")).toBe(true);
71
+ });
72
+
73
+ it("锚点仅作为子串出现返回 false", () => {
74
+ expect(hasAnchor("# 参考 ojm:mount 文档", "# ojm:mount")).toBe(false);
75
+ expect(hasAnchor("# ojm:mounts", "# ojm:mount")).toBe(false);
76
+ });
77
+
78
+ it("锚点不存在返回 false", () => {
79
+ expect(hasAnchor("a: 1\nb: 2", "# ojm:mount")).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe("ensureReloadApi(P1-10,D3 一次性注入集收口:S1-1 定稿无需改 mounts)", () => {
84
+ it("无 reload_api 键且锚点在 → 锚点下插入 reload_api: true(缩进继承、注释逐字保留),changed=true", () => {
85
+ const content = "server:\n port: 8080\n # 用户注释保留\n # ojm:reload\ndb:\n default: x";
86
+ const r = ensureReloadApi(content);
87
+ expect(r.changed).toBe(true);
88
+ expect(r.content).toBe("server:\n port: 8080\n # 用户注释保留\n # ojm:reload\n reload_api: true\ndb:\n default: x");
89
+ });
90
+
91
+ it("reload_api: true 已在 → no-op(无锚点也不报错),changed=false", () => {
92
+ const content = "server:\n reload_api: true\n port: 1";
93
+ const r = ensureReloadApi(content);
94
+ expect(r.changed).toBe(false);
95
+ expect(r.content).toBe(content);
96
+ });
97
+
98
+ it("显式 reload_api: false → 拒绝(用户主动关闭,不偷偷翻转),message 可读", () => {
99
+ expect(() => ensureReloadApi("server:\n reload_api: false # 运维明确关闭"))
100
+ .toThrowError(/reload_api/);
101
+ });
102
+
103
+ it("无键且锚点缺失 → 可读报错(不猜插入位置,锚点随模板发布,P1-16 接线)", () => {
104
+ expect(() => ensureReloadApi("server:\n port: 1")).toThrowError(/# ojm:reload/);
105
+ });
106
+ });
107
+
108
+ describe("bumpInstallSeq(B-11 reload 触发面:vars 安装序号自增,PRD E5 实测 vars 改动可热生效)", () => {
109
+ it("0 → 1 自增,其余行(注释/缩进/键序)逐字保留", () => {
110
+ const content = "mounts: []\nvars:\n ojm_install_seq: 0\n# 尾注释保留";
111
+ const r = bumpInstallSeq(content);
112
+ expect(r.seq).toBe(1);
113
+ expect(r.content).toBe("mounts: []\nvars:\n ojm_install_seq: 1\n# 尾注释保留");
114
+ });
115
+
116
+ it("多次调用累加(N → N+1),行内尾注释原样保留", () => {
117
+ const content = "vars:\n ojm_install_seq: 41 # 安装序号";
118
+ const r1 = bumpInstallSeq(content);
119
+ expect(r1.seq).toBe(42);
120
+ expect(r1.content).toBe("vars:\n ojm_install_seq: 42 # 安装序号");
121
+ const r2 = bumpInstallSeq(r1.content);
122
+ expect(r2.seq).toBe(43);
123
+ });
124
+
125
+ it("键缺失 → 可读报错(用户删行 = 显式退出热更触发,安装器不猜位置)", () => {
126
+ expect(() => bumpInstallSeq("vars:\n other: 1")).toThrowError(/ojm_install_seq/);
127
+ });
128
+
129
+ it("值非数字(行存在但形状不符)→ 视同缺失,可读报错不猜", () => {
130
+ expect(() => bumpInstallSeq("vars:\n ojm_install_seq: abc")).toThrowError(/ojm_install_seq/);
131
+ });
132
+ });
133
+
134
+ describe("模板 config.yaml 随发锚点(P1-16 接线②:ensureReloadApi 键缺失插入路径的闭环依据)", () => {
135
+ it("模板 config.yaml 带整行 # ojm:reload 锚点;键在 → ensureReloadApi no-op", async () => {
136
+ const cfg = await fsp.readFile(new URL("../../config.yaml", import.meta.url), "utf8");
137
+ expect(hasAnchor(cfg, "# ojm:reload")).toBe(true);
138
+ const r = ensureReloadApi(cfg);
139
+ expect(r.changed).toBe(false);
140
+ });
141
+
142
+ it("用户删键保锚点 → ensureReloadApi 锚点下插回 reload_api: true(缩进继承)", async () => {
143
+ const cfg = await fsp.readFile(new URL("../../config.yaml", import.meta.url), "utf8");
144
+ const stripped = cfg.split("\n").filter(l => !/^\s*reload_api\s*:/.test(l)).join("\n");
145
+ const r = ensureReloadApi(stripped);
146
+ expect(r.changed).toBe(true);
147
+ expect(r.content).toContain("reload_api: true");
148
+ expect(r.content).toContain("# ojm:reload");
149
+ });
150
+ });
151
+
152
+ describe("p2-15③ C2-26 anonymous 运行时接线(appendAnonymousPaths:config 独占模型文本手术)", () => {
153
+ const BASE = "auth:\n anonymous_paths:\n - /auth/login\n # 行内注释(模板 config 真形态)\n - /system/login-methods\n";
154
+
155
+ it("追加方案匿名端点:列表尾部插入、统一补前导 /、缩进继承", () => {
156
+ const r = appendAnonymousPaths(BASE, ["demo-solution/ping", "demo-solution/health"]);
157
+ expect(r.added).toEqual(["/demo-solution/ping", "/demo-solution/health"]);
158
+ expect(r.content).toContain(" - /demo-solution/ping");
159
+ expect(r.content).toContain(" - /demo-solution/health");
160
+ // 注释逐字保留 + 既有项不动
161
+ expect(r.content).toContain("# 行内注释(模板 config 真形态)");
162
+ expect(r.content).toContain("- /auth/login");
163
+ expect(r.content).toContain("- /system/login-methods");
164
+ });
165
+
166
+ it("幂等:已存在(trim 相等)不重复插入", () => {
167
+ const once = appendAnonymousPaths(BASE, ["demo-solution/ping"]);
168
+ const twice = appendAnonymousPaths(once.content, ["demo-solution/ping"]);
169
+ expect(twice.added).toEqual([]);
170
+ expect(twice.content).toBe(once.content);
171
+ expect(once.content.match(/demo-solution\/ping/g)).toHaveLength(1);
172
+ });
173
+
174
+ it("键缺失 = 可读报错不猜位置(用户删键 = 显式退出匿名模型)", () => {
175
+ expect(() => appendAnonymousPaths("server:\n reload_api: true\n", ["x/y"])).toThrow(/anonymous_paths/);
176
+ });
177
+ });
@@ -0,0 +1,103 @@
1
+ // BDD:tgz 成员守卫(R1,P1-2)。zip-slip 防护的校验核。
2
+ // 输入契约:`tar -tvzf` 的逐行输出;成员名取行内最后一个空格分隔字段;
3
+ // 条目类型取权限串首字符(l=symlink、h=hardlink);symlink 行成员名取箭头左侧。
4
+ // 拒绝:绝对路径、任何 `..` 段、l/h 条目、空成员名、反斜杠路径;`./` 前缀相对合法。
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { validateTarListing } from "../../src/_platform/_lifecycle/tgz-guard";
8
+
9
+ describe("validateTarListing(R1)", () => {
10
+ it("正常成员列表通过", () => {
11
+ expect(validateTarListing([
12
+ "drwxr-xr-x 0 ever staff 0 10 17 12:00 wiki-0.2.0/",
13
+ "-rw-r--r-- 0 ever staff 42 10 17 12:00 wiki-0.2.0/src/manifest.yaml",
14
+ ])).toEqual({ ok: true });
15
+ });
16
+
17
+ it("嵌套合法相对路径通过", () => {
18
+ expect(validateTarListing([
19
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 wiki-0.2.0/src/a/b/c.yaml",
20
+ ])).toEqual({ ok: true });
21
+ });
22
+
23
+ it("`./` 前缀视为相对合法", () => {
24
+ expect(validateTarListing([
25
+ "-rw-r--r-- 0 ever staff 42 10 17 12:00 ./wiki-0.2.0/src/manifest.yaml",
26
+ ])).toEqual({ ok: true });
27
+ });
28
+
29
+ it("成员名含连续点但非 `..` 段通过", () => {
30
+ expect(validateTarListing([
31
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 wiki-0.2.0/a..b/file",
32
+ ])).toEqual({ ok: true });
33
+ });
34
+
35
+ it("`../evil` 拒绝并报 offender", () => {
36
+ expect(validateTarListing([
37
+ "drwxr-xr-x 0 ever staff 0 10 17 12:00 wiki-0.2.0/",
38
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 ../evil",
39
+ ])).toEqual({ ok: false, offender: "../evil" });
40
+ });
41
+
42
+ it("路径中段的 `..` 拒绝", () => {
43
+ expect(validateTarListing([
44
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 wiki-0.2.0/../../etc/passwd",
45
+ ])).toEqual({ ok: false, offender: "wiki-0.2.0/../../etc/passwd" });
46
+ });
47
+
48
+ it("绝对路径拒绝", () => {
49
+ expect(validateTarListing([
50
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 /etc/passwd",
51
+ ])).toEqual({ ok: false, offender: "/etc/passwd" });
52
+ });
53
+
54
+ it("symlink(l 类型)拒绝,offender 取箭头左侧成员名", () => {
55
+ expect(validateTarListing([
56
+ "lrwxr-xr-x 0 ever staff 0 10 17 12:00 link -> /etc",
57
+ ])).toEqual({ ok: false, offender: "link" });
58
+ });
59
+
60
+ it("hardlink(h 类型)拒绝", () => {
61
+ expect(validateTarListing([
62
+ "hrwxr-xr-x 0 ever staff 0 10 17 12:00 wiki-0.2.0/dup",
63
+ ])).toEqual({ ok: false, offender: "wiki-0.2.0/dup" });
64
+ });
65
+
66
+ it("空成员名(无字段的退化行)拒绝", () => {
67
+ expect(validateTarListing([""])).toEqual({ ok: false, offender: "" });
68
+ });
69
+
70
+ it("windows 风格反斜杠路径防御性拒绝", () => {
71
+ expect(validateTarListing([
72
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 wiki-0.2.0\\src\\manifest.yaml",
73
+ ])).toEqual({ ok: false, offender: "wiki-0.2.0\\src\\manifest.yaml" });
74
+ expect(validateTarListing([
75
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 ..\\evil",
76
+ ])).toEqual({ ok: false, offender: "..\\evil" });
77
+ });
78
+
79
+ it("空格成员名内藏 `..` 逃逸拒绝(R1 评审构造行)", () => {
80
+ expect(validateTarListing([
81
+ "-rw-r--r-- 0 ever staff 7 10 17 15:01 ./wiki-0.2.0/../../evil pwn",
82
+ ])).toEqual({ ok: false, offender: "./wiki-0.2.0/../../evil" });
83
+ });
84
+
85
+ it("空格成员名以 `/` 开头的逃逸形态拒绝", () => {
86
+ expect(validateTarListing([
87
+ "-rw-r--r-- 0 ever staff 7 10 17 15:01 /etc x",
88
+ ])).toEqual({ ok: false, offender: "/etc" });
89
+ });
90
+
91
+ it("`tar: …` 警告杂行拒绝(tar 的 stderr 混入)", () => {
92
+ expect(validateTarListing([
93
+ "tar: ../evil",
94
+ ])).toEqual({ ok: false, offender: "../evil" });
95
+ });
96
+
97
+ it("多个坏成员时报第一个 offender", () => {
98
+ expect(validateTarListing([
99
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 /abs",
100
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 ../evil",
101
+ ])).toEqual({ ok: false, offender: "/abs" });
102
+ });
103
+ });
@@ -0,0 +1,310 @@
1
+ // BDD:宿主信任库(P2-2,契约 v1.3 §2.11 C2-38/39,G3 双轨第一轨 / G5)+ 市场运维端点(superadmin,R9)。
2
+ // store 层:空库/幂等/首次绑定不可改/多 key/吊销/损坏 fail-closed/.bak 回落/并发读不见半截/审计。
3
+ // 端点层:GET /market/trust、POST /market/trust/key、DELETE /market/trust/key?key_id=(del 动词 + query,system/roles 同款先例)。
4
+ /* eslint-disable test/prefer-lowercase-title -- 端点动词/路径是验收锚点,保留大写(market-api.spec 同约定) */
5
+ import type { FsFace } from "../../src/_platform/_lifecycle/atomic";
6
+ import type { MarketEnv } from "../../src/_platform/market/api";
7
+ import { Buffer } from "node:buffer";
8
+ import { createHash } from "node:crypto";
9
+ import * as fsp from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+ import { createTrustStore, isValidKeyId } from "../../src/_platform/_lifecycle/trust";
14
+ import { invalidateAllUsers } from "../../src/_platform/_shared/service/access";
15
+ import { createMarketApi, marketEnv } from "../../src/_platform/market/api";
16
+ import { installDb } from "../fixture";
17
+
18
+ const g = globalThis as any;
19
+
20
+ const PUB_A = "a".repeat(64);
21
+ const PUB_B = "b".repeat(64);
22
+
23
+ function nodeFsFace(): FsFace {
24
+ return {
25
+ writeFile: (path, data) => fsp.writeFile(path, data),
26
+ readFile: path => fsp.readFile(path),
27
+ mkdir: (path, opts) => fsp.mkdir(path, opts),
28
+ remove: (path, opts) => fsp.rm(path, opts),
29
+ rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
30
+ copyFile: (src, dest) => fsp.copyFile(src, dest),
31
+ readdir: path => fsp.readdir(path),
32
+ };
33
+ }
34
+
35
+ /** sha256Hex 指纹面(契约 §2.11;真机由 oj crypto 增补面提供,测试经 opts.fingerprintOf 注入同值实现) */
36
+ function fingerprintOf(publicKeyHex: string): string {
37
+ return createHash("sha256").update(Buffer.from(publicKeyHex, "hex")).digest("hex");
38
+ }
39
+
40
+ let root: string;
41
+ let trustPath: string;
42
+ let auditLog: { action: string, target: string, detail: unknown, level?: number }[];
43
+
44
+ beforeEach(async () => {
45
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-trust-"));
46
+ trustPath = join(root, ".solutions", "trusted-keys.yaml");
47
+ g.fs = nodeFsFace();
48
+ auditLog = [];
49
+ });
50
+
51
+ afterEach(async () => {
52
+ g.fs = undefined;
53
+ await fsp.rm(root, { recursive: true, force: true });
54
+ });
55
+
56
+ function store(): ReturnType<typeof createTrustStore> {
57
+ return createTrustStore(trustPath, { audit: e => auditLog.push({ action: e.action, target: e.target, detail: e.detail, level: e.level }), fingerprintOf });
58
+ }
59
+
60
+ describe("信任库 store(C2-38/39)", () => {
61
+ it("空库:list 空数组 / get null,不报错", async () => {
62
+ const s = store();
63
+ expect(await s.list()).toEqual([]);
64
+ expect(await s.get("ojm.test")).toBeNull();
65
+ });
66
+
67
+ it("add → get/list 命中;落盘为受限 YAML 子集", async () => {
68
+ const s = store();
69
+ expect(await s.add("ojm.test", PUB_A, { operator: "admin" })).toBe("added");
70
+ expect(await s.get("ojm.test")).toBe(PUB_A);
71
+ expect(await s.list()).toEqual([{ key_id: "ojm.test", public_key: PUB_A, added_at: expect.any(Number) }]);
72
+ const text = await fsp.readFile(trustPath, "utf8");
73
+ expect(text).toMatch(/^trusted_keys:\n {2}ojm\.test:\n {4}public_key: "a{64}"\n {4}added_at: \d+\n$/);
74
+ });
75
+
76
+ it("同 key 同公钥幂等:exists_same,盘上内容不变", async () => {
77
+ const s = store();
78
+ await s.add("ojm.test", PUB_A);
79
+ const before = await fsp.readFile(trustPath, "utf8");
80
+ expect(await s.add("ojm.test", PUB_A)).toBe("exists_same");
81
+ expect(await fsp.readFile(trustPath, "utf8")).toBe(before);
82
+ });
83
+
84
+ it("首次绑定不可改:同 key_id 异公钥 = 409 trust_bound_mismatch(含双方指纹),库内原值不动", async () => {
85
+ const s = store();
86
+ await s.add("ojm.test", PUB_A);
87
+ await expect(s.add("ojm.test", PUB_B)).rejects.toMatchObject({ status: 409, message: expect.stringContaining("首次绑定不可改") });
88
+ await expect(s.add("ojm.test", PUB_B)).rejects.toThrow(/库内指纹 [0-9a-f]{64} ≠ 新钥指纹 [0-9a-f]{64}/);
89
+ expect(await s.get("ojm.test")).toBe(PUB_A);
90
+ });
91
+
92
+ it("多 key 共存(轮换过渡期):两个 key_id 均在列", async () => {
93
+ const s = store();
94
+ await s.add("ojm.old", PUB_A);
95
+ await s.add("ojm.new", PUB_B);
96
+ const rows = await s.list();
97
+ expect(rows.map(r => r.key_id)).toEqual(["ojm.new", "ojm.old"]); // 字典序
98
+ });
99
+
100
+ it("吊销:remove → get null;再 remove → false;审计留痕", async () => {
101
+ const s = store();
102
+ await s.add("ojm.test", PUB_A, { operator: "admin" });
103
+ expect(await s.remove("ojm.test", { operator: "admin" })).toBe(true);
104
+ expect(await s.get("ojm.test")).toBeNull();
105
+ expect(await s.remove("ojm.test")).toBe(false);
106
+ expect(auditLog.map(e => e.action)).toEqual(["solution.trust.add", "solution.trust.revoke"]);
107
+ expect(auditLog[0]).toMatchObject({ target: "ojm.test", detail: { key_id: "ojm.test", operator: "admin", fingerprint: fingerprintOf(PUB_A) } });
108
+ });
109
+
110
+ it("审计 operator 不可得 → 记 unknown 且告警级 level=4(不静默 null,P2-5)", async () => {
111
+ const s = store();
112
+ await s.add("ojm.test", PUB_A); // 无 operator
113
+ expect(auditLog[0]?.level).toBe(4);
114
+ expect(auditLog[0]?.detail).toMatchObject({ operator: "unknown" });
115
+ await s.remove("ojm.test"); // 无 operator
116
+ expect(auditLog[1]?.level).toBe(4);
117
+ expect(auditLog[1]?.detail).toMatchObject({ operator: "unknown" });
118
+ });
119
+
120
+ it("审计 operator 可得 → 记真值且 info 级 level=6", async () => {
121
+ const s = store();
122
+ await s.add("ojm.test", PUB_A, { operator: "admin" });
123
+ expect(auditLog[0]?.level).toBe(6);
124
+ expect(auditLog[0]?.detail).toMatchObject({ operator: "admin" });
125
+ });
126
+
127
+ it("key_id 命名空间违规拒(C2-37):单段裸名 / 大写 / 空段", async () => {
128
+ const s = store();
129
+ await expect(s.add("test", PUB_A)).rejects.toMatchObject({ status: 400 });
130
+ await expect(s.add("Default.x", PUB_A)).rejects.toMatchObject({ status: 400 });
131
+ await expect(s.add("a..b", PUB_A)).rejects.toMatchObject({ status: 400 });
132
+ expect(isValidKeyId("ojm.test")).toBe(true);
133
+ expect(isValidKeyId("pub.team.key-2")).toBe(true);
134
+ expect(isValidKeyId("test")).toBe(false);
135
+ });
136
+
137
+ it("public_key 非 64hex 拒(400)", async () => {
138
+ const s = store();
139
+ await expect(s.add("ojm.test", "abc")).rejects.toMatchObject({ status: 400 });
140
+ await expect(s.add("ojm.test", "A".repeat(64))).rejects.toMatchObject({ status: 400 });
141
+ });
142
+
143
+ it("损坏 fail-closed:主与 .bak 皆不可解析 → 500 可读报错(含路径与修法)", async () => {
144
+ await fsp.mkdir(join(root, ".solutions"), { recursive: true });
145
+ await fsp.writeFile(trustPath, "trusted_keys: [1, 2, 3]\n");
146
+ const s = store();
147
+ await expect(s.list()).rejects.toMatchObject({ status: 500, message: expect.stringContaining(trustPath) });
148
+ });
149
+
150
+ it(".bak 回落:主损坏 + .bak 完好 → 自动回落并留 note;下次写入自愈", async () => {
151
+ const s0 = store();
152
+ await s0.add("ojm.test", PUB_A);
153
+ await s0.add("ojm.other", PUB_B); // 第二次写后 .bak = 只含 ojm.test 的前一版
154
+ const bak = await fsp.readFile(`${trustPath}.bak`, "utf8");
155
+ expect(bak).toContain("ojm.test");
156
+ expect(bak).not.toContain("ojm.other");
157
+
158
+ await fsp.writeFile(trustPath, "garbage: {{\n");
159
+ const s1 = store();
160
+ const rows = await s1.list();
161
+ expect(rows.map(r => r.key_id)).toEqual(["ojm.test"]); // 回落 .bak
162
+ expect(s1.corruptNote?.()).toContain("已自动回落");
163
+
164
+ await s1.add("ojm.third", PUB_A); // 写入自愈:主文件重建、note 清零、.bak 不被垃圾覆盖
165
+ const healed = await fsp.readFile(trustPath, "utf8");
166
+ expect(healed).toContain("ojm.test");
167
+ expect(healed).toContain("ojm.third");
168
+ expect(await fsp.readFile(`${trustPath}.bak`, "utf8")).toBe(bak);
169
+ expect((store()).corruptNote?.()).toBeNull();
170
+ });
171
+
172
+ it("并发读不见半截:写与读交叉进行,读方永远得到完整旧版或完整新版", async () => {
173
+ const s = store();
174
+ // 单写者顺序写(trust 变更经端点/CLI 串行到达;跨进程并发锁属安装互斥面,R17/R31)
175
+ const writer = (async () => {
176
+ for (let i = 0; i < 20; i++)
177
+ await s.add(`pub.k${i}`, i % 2 === 0 ? PUB_A : PUB_B);
178
+ })();
179
+ const sizes: number[] = [];
180
+ for (let round = 0; round < 500; round++) {
181
+ const rows = await s.list(); // 半截文件会在此抛错(严格解析)
182
+ sizes.push(rows.length);
183
+ }
184
+ await writer;
185
+ expect(Math.max(...sizes)).toBeLessThanOrEqual(20);
186
+ expect((await s.list()).length).toBe(20);
187
+ });
188
+ });
189
+
190
+ describe("信任库运维端点(superadmin,R9)", () => {
191
+ let sharedRoot: string;
192
+ let last: { status: number, data?: unknown, msg?: string } | null;
193
+
194
+ beforeEach(async () => {
195
+ sharedRoot = join(root, "modules");
196
+ const dbo = installDb(); // 注入 g.db(node:sqlite 内存库 + 真实迁移)
197
+ dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
198
+ g.yaml = { parse: async (t: string) => JSON.parse(t) };
199
+ g.http = { user: { id: 1 }, query: {}, body: {} };
200
+ g.json = {
201
+ ok: (data: unknown) => { last = { status: 200, data }; },
202
+ fail: (status: number, msg: string) => { last = { status, msg }; },
203
+ };
204
+ last = null;
205
+ });
206
+
207
+ afterEach(async () => {
208
+ g.db = undefined;
209
+ g.http = undefined;
210
+ g.json = undefined;
211
+ g.yaml = undefined;
212
+ invalidateAllUsers();
213
+ });
214
+
215
+ function makeEnv(): MarketEnv {
216
+ return {
217
+ sharedRoot,
218
+ staging: join(sharedRoot, ".staging"),
219
+ taskDir: join(sharedRoot, ".install-tasks"),
220
+ statePath: join(sharedRoot, ".solutions", "state.yaml"),
221
+ trustPath,
222
+ dbPath: join(root, "db.sqlite"),
223
+ siteModulesDir: join(root, "dist", "modules"),
224
+ modulesJsonPath: join(root, "dist", "modules.json"),
225
+ apiPrefix: "/api",
226
+ };
227
+ }
228
+
229
+ it("未登录 / 非 superadmin 一律 403(拒权不泄露存在性,同 market 编排面口径)", async () => {
230
+ const api = createMarketApi(makeEnv());
231
+ g.http = {}; // 无 user:currentAccess deny(403,不区分「未登录」与「无权」)
232
+ await api.trust.get();
233
+ expect(last).toMatchObject({ status: 403 });
234
+ g.http = { user: { id: 2 } }; // 登录但非 superadmin
235
+ await api.trust.get();
236
+ expect(last).toMatchObject({ status: 403 });
237
+ });
238
+
239
+ it("GET trust:空库返回 {keys: [], note: null}", async () => {
240
+ const api = createMarketApi(makeEnv());
241
+ await api.trust.get();
242
+ expect(last).toMatchObject({ status: 200, data: { keys: [], note: null } });
243
+ });
244
+
245
+ it("POST trust/key:缺参 400;坏 key_id 400;正常 added → 幂等 exists_same;撞绑定 409", async () => {
246
+ const api = createMarketApi(makeEnv());
247
+ g.http.body = {};
248
+ await api.trust.key.post();
249
+ expect(last).toMatchObject({ status: 400 });
250
+
251
+ g.http.body = { key_id: "test", public_key: PUB_A };
252
+ await api.trust.key.post();
253
+ expect(last).toMatchObject({ status: 400 });
254
+
255
+ g.http.body = { key_id: "ojm.test", public_key: PUB_A };
256
+ await api.trust.key.post();
257
+ expect(last).toMatchObject({ status: 200, data: { result: "added" } });
258
+ await api.trust.key.post();
259
+ expect(last).toMatchObject({ status: 200, data: { result: "exists_same" } });
260
+
261
+ g.http.body = { key_id: "ojm.test", public_key: PUB_B };
262
+ await api.trust.key.post();
263
+ expect(last).toMatchObject({ status: 409 });
264
+ });
265
+
266
+ it("DELETE trust/key:吊销成功 / 无此 key 皆可断言;审计落 sys_oplog(operator=admin)", async () => {
267
+ const api = createMarketApi(makeEnv());
268
+ g.http.body = { key_id: "ojm.test", public_key: PUB_A };
269
+ await api.trust.key.post();
270
+ await new Promise(r => setTimeout(r, 20)); // auditOp fire-and-forget:等落库
271
+
272
+ g.http.query = { key_id: "ojm.test" };
273
+ await api.trust.key.del();
274
+ expect(last).toMatchObject({ status: 200, data: { revoked: true } });
275
+
276
+ await api.trust.key.del();
277
+ expect(last).toMatchObject({ status: 200, data: { revoked: false } });
278
+
279
+ g.http.query = {};
280
+ await api.trust.key.del();
281
+ expect(last).toMatchObject({ status: 400 });
282
+
283
+ const rows = await (g.db.query as (sql: string, p?: unknown[]) => Promise<Array<Record<string, unknown>>>)("select action, target, detail from sys_oplog where action like 'solution.trust.%' order by id");
284
+ expect(rows.map(r => r.action)).toEqual(["solution.trust.add", "solution.trust.revoke"]);
285
+ expect(rows[0]).toMatchObject({ target: "ojm.test" });
286
+ expect(JSON.parse(String(rows[0].detail))).toMatchObject({ key_id: "ojm.test", operator: "admin" });
287
+ });
288
+ });
289
+
290
+ // U6 信任库加固(P3-1):信任库路径须移出第三方方案模块的 fs jail(modules/ 安装子树),
291
+ // 并经 platform.trusted_paths 显式授权给 _platform 特权模块——防止伪造信任锚(P2-2 红线 spike)。
292
+ describe("U6 信任库路径加固(P3-1)", () => {
293
+ it("marketEnv().trustPath 不在 modules/ 安装子树内", () => {
294
+ const env = marketEnv();
295
+ // 关键不变量:第三方方案模块(非 _platform)的 fs 读写范围 = fs.root,
296
+ // 落在 modules/ 下即处于其 jail 内可被写入;信任库必须在其外。
297
+ expect(env.trustPath.startsWith("modules/")).toBe(false);
298
+ expect(env.trustPath).toBe(".solutions/trusted-keys.yaml");
299
+ });
300
+
301
+ it("模板 config.yaml 文档化 U6 trusted_paths 授权要求(部署侧须补声明)", async () => {
302
+ const cfg = await fsp.readFile(join(__dirname, "..", "..", "config.yaml"), "utf8");
303
+ // 信任库存放根须落 platform.trusted_paths 授权 + 移出 modules/ 安装子树(防第三方方案模块伪造信任锚)。
304
+ // 模板不硬编码 trusted_paths 条目(oj 对不存在路径装配 fail-fast,会致 oj serve 起服失败),
305
+ // 故此处钉「要求被文档化」这一不变量,部署侧据此补声明。
306
+ expect(cfg).toContain("trusted_paths");
307
+ expect(cfg).toContain("U6");
308
+ expect(cfg).toContain(".solutions/trusted-keys.yaml");
309
+ });
310
+ });