@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,385 @@
1
+ // BDD:深检后端四项 + 端点(P2-7,C2-42)+ SSRF 三钉。
2
+ // 纯 diff 函数 / 探针分类 / SSRF 钉(出站拦截 / header 清洗 / 不跟随重定向)+ 端点门(401/403/404)+ 编排。
3
+ /* eslint-disable test/prefer-lowercase-title -- 端点动词/路径是验收锚点,保留大写(market-api.spec 同约定) */
4
+ import type { FsFace } from "../../src/_platform/_lifecycle/atomic";
5
+ import type { SolutionRouteRow } from "../../src/_platform/_lifecycle/verify-checks";
6
+ import * as fsp from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import {
11
+ declaredMigrationsOf,
12
+ declaredSettingsOf,
13
+ diffLedger,
14
+ diffPermRows,
15
+ diffSettingRows,
16
+ diffWebIntegrity,
17
+ probeUrlOf,
18
+ sha384B64,
19
+
20
+ verifyRouteReachability,
21
+ } from "../../src/_platform/_lifecycle/verify-checks";
22
+ import { siteOrigin } from "../../src/_platform/market/api";
23
+ import { createSolutionsApi, solutionsEnv } from "../../src/_platform/solutions/api";
24
+ import { installDb } from "../fixture";
25
+
26
+ const g = globalThis as any;
27
+
28
+ function nodeFsFace(): FsFace {
29
+ return {
30
+ writeFile: (path, data) => fsp.writeFile(path, data),
31
+ readFile: path => fsp.readFile(path),
32
+ mkdir: (path, opts) => fsp.mkdir(path, opts),
33
+ remove: (path, opts) => fsp.rm(path, opts),
34
+ rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
35
+ copyFile: (src, dest) => fsp.copyFile(src, dest),
36
+ readdir: path => fsp.readdir(path),
37
+ };
38
+ }
39
+
40
+ // ============ SSRF 钉①:探测 URL 永不出站 ============
41
+ describe("SSRF 钉①:探测 URL 永不出站(probeUrlOf)", () => {
42
+ const origin = { proto: "http", host: "localhost:9778" };
43
+ it("相对路径 → 绝对同源 URL", () => {
44
+ expect(probeUrlOf("/api/x", origin)).toBe("http://localhost:9778/api/x");
45
+ });
46
+ it("scheme:// 出站 → 抛 400", () => {
47
+ expect(() => probeUrlOf("http://evil.com/x", origin)).toThrow(/SSRF/);
48
+ });
49
+ it("协议相对 // → 抛 400", () => {
50
+ expect(() => probeUrlOf("//evil.com/x", origin)).toThrow(/SSRF/);
51
+ });
52
+ it("非 / 开头 → 抛 400", () => {
53
+ expect(() => probeUrlOf("api/x", origin)).toThrow(/SSRF/);
54
+ });
55
+ });
56
+
57
+ // ============ SSRF 钉②:siteOrigin header 清洗 ============
58
+ describe("SSRF 钉②:siteOrigin 清洗 x-forwarded-proto", () => {
59
+ afterEach(() => {
60
+ g.http = undefined;
61
+ });
62
+ it("取首个且去空白", () => {
63
+ g.http = { headers: { "host": "h", "x-forwarded-proto": " https , http " } };
64
+ expect(siteOrigin()).toEqual({ proto: "https", host: "h" });
65
+ });
66
+ it("无 host → null(非请求上下文跳过探针)", () => {
67
+ g.http = { headers: {} };
68
+ expect(siteOrigin()).toBeNull();
69
+ });
70
+ });
71
+
72
+ // ============ SSRF 钉③:探针不跟随重定向 ============
73
+ describe("SSRF 钉③:探针不跟随重定向", () => {
74
+ const origin = { proto: "http", host: "localhost:9778" };
75
+ const routes: SolutionRouteRow[] = [{ path: "/api/a", module: "m", category: "main", order: 1, title_key: null, required_permission: null, enabled: true }];
76
+ it("302 裸返回 → 判异常(不跟随)且 init.redirect=manual", async () => {
77
+ const fetchFn = vi.fn(async (_url: string, init?: { redirect?: string }) => {
78
+ expect(init?.redirect).toBe("manual");
79
+ return { ok: false, status: 302, arrayBuffer: async () => new ArrayBuffer(0) };
80
+ });
81
+ const items = await verifyRouteReachability(fetchFn, routes, origin);
82
+ expect(items).toEqual([{ id: "routes", severity: "error", subject: "/api/a", advise_key: "lifecycle.verify.routes.redirect" }]);
83
+ expect(fetchFn).toHaveBeenCalledTimes(1);
84
+ });
85
+ });
86
+
87
+ // ============ 路由可达性分类 ============
88
+ describe("路由可达性分类(verifyRouteReachability)", () => {
89
+ const origin = { proto: "http", host: "localhost:9778" };
90
+ const mk = (path: string, perm: string | null): SolutionRouteRow => ({ path, module: "m", category: "main", order: 1, title_key: null, required_permission: perm, enabled: true });
91
+ it("2xx → 不打项", async () => {
92
+ const f = async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) });
93
+ expect(await verifyRouteReachability(f, [mk("/a", null)], origin)).toEqual([]);
94
+ });
95
+ it("401 → 不打项(需登录仍可达)", async () => {
96
+ const f = async () => ({ ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) });
97
+ expect(await verifyRouteReachability(f, [mk("/a", null)], origin)).toEqual([]);
98
+ });
99
+ it("404 → error(anomaly)", async () => {
100
+ const f = async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) });
101
+ expect(await verifyRouteReachability(f, [mk("/a", null)], origin)).toEqual([{ id: "routes", severity: "error", subject: "/a", advise_key: "lifecycle.verify.routes.anomaly" }]);
102
+ });
103
+ it("5xx → error(anomaly)", async () => {
104
+ const f = async () => ({ ok: false, status: 500, arrayBuffer: async () => new ArrayBuffer(0) });
105
+ const items = await verifyRouteReachability(f, [mk("/a", null)], origin);
106
+ expect(items[0]?.severity).toBe("error");
107
+ expect(items[0]?.advise_key).toBe("lifecycle.verify.routes.anomaly");
108
+ });
109
+ it("403 + 声明权限点 → warn(unauthenticated)", async () => {
110
+ const f = async () => ({ ok: false, status: 403, arrayBuffer: async () => new ArrayBuffer(0) });
111
+ expect(await verifyRouteReachability(f, [mk("/a", "m.perm")], origin)).toEqual([{ id: "routes", severity: "warn", subject: "/a", advise_key: "lifecycle.verify.routes.unauthenticated" }]);
112
+ });
113
+ it("403 无权限点 → error(blocked)", async () => {
114
+ const f = async () => ({ ok: false, status: 403, arrayBuffer: async () => new ArrayBuffer(0) });
115
+ expect(await verifyRouteReachability(f, [mk("/a", null)], origin)).toEqual([{ id: "routes", severity: "error", subject: "/a", advise_key: "lifecycle.verify.routes.blocked" }]);
116
+ });
117
+ it("连不通 → error(unreachable)", async () => {
118
+ const f = async () => {
119
+ throw new Error("ECONNREFUSED");
120
+ };
121
+ expect(await verifyRouteReachability(f, [mk("/a", null)], origin)).toEqual([{ id: "routes", severity: "error", subject: "/a", advise_key: "lifecycle.verify.routes.unreachable" }]);
122
+ });
123
+ it("出站路径 → 400 上抛(SSRF 钉①)", async () => {
124
+ const f = vi.fn(async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }));
125
+ await expect(verifyRouteReachability(f, [mk("http://evil.com", null)], origin)).rejects.toMatchObject({ status: 400 });
126
+ });
127
+ it("origin null → info 跳过(不发起请求)", async () => {
128
+ const f = vi.fn(async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }));
129
+ expect(await verifyRouteReachability(f, [mk("/a", null)], null)).toEqual([{ id: "routes", severity: "info", subject: "*", advise_key: "lifecycle.verify.routes.skipped" }]);
130
+ expect(f).not.toHaveBeenCalled();
131
+ });
132
+ });
133
+
134
+ // P3-10(P2-16#2):深检静态托管绿路径——web 路由由 SPA/静态宿主 200 即视作可达且健康(绿),
135
+ // 与 API 路由同口径;诚实 404 之外,静态托管形态下不应误报 anomaly(检测能力不退化)。
136
+ describe("p3-10 深检静态托管绿路径(web route 由 SPA/静态宿主 200 ⇒ 不打项)", () => {
137
+ const origin = { proto: "http", host: "localhost:9778" };
138
+ const mkWeb = (path: string, cat: "main" | "hidden" = "main", perm: string | null = null): SolutionRouteRow => ({ path, module: "demo-solution", category: cat, order: 1, title_key: null, required_permission: perm, enabled: true });
139
+ const okFetch = async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) });
140
+ it("web 路由(main)由静态宿主 200 ⇒ 不打项(绿)", async () => {
141
+ expect(await verifyRouteReachability(okFetch, [mkWeb("/demo-solution")], origin)).toEqual([]);
142
+ });
143
+ it("web 路由(main + hidden)同由静态宿主 200 ⇒ 全绿(ok 语义不退化)", async () => {
144
+ expect(await verifyRouteReachability(okFetch, [mkWeb("/demo-solution"), mkWeb("/demo-solution/ping", "hidden")], origin)).toEqual([]);
145
+ });
146
+ it("web 路由静态宿主缺失(404)⇒ error(anomaly)(与 API 路由同口径,诚实检测不退化)", async () => {
147
+ const f = async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) });
148
+ expect(await verifyRouteReachability(f, [mkWeb("/demo-solution")], origin)).toEqual([{ id: "routes", severity: "error", subject: "/demo-solution", advise_key: "lifecycle.verify.routes.anomaly" }]);
149
+ });
150
+ });
151
+
152
+ // ============ 纯 diff 函数 ============
153
+ describe("RBAC 完整性 diffPermRows", () => {
154
+ it("声明缺失 → error", () => {
155
+ expect(diffPermRows(["a.perm", "b.perm"], new Set(["a.perm"]))).toEqual([{ id: "rbac", severity: "error", subject: "b.perm", advise_key: "lifecycle.verify.rbac.missing_perm" }]);
156
+ });
157
+ it("全齐 → 空", () => {
158
+ expect(diffPermRows(["a.perm"], new Set(["a.perm"]))).toEqual([]);
159
+ });
160
+ });
161
+
162
+ describe("设置完整性 diffSettingRows", () => {
163
+ it("声明缺失 → error(永不含值)", () => {
164
+ expect(diffSettingRows(["m:s.k1", "m:s.k2"], new Set(["m:s.k1"]))).toEqual([{ id: "settings", severity: "error", subject: "m:s.k2", advise_key: "lifecycle.verify.settings.missing_key" }]);
165
+ });
166
+ it("全齐 → 空", () => {
167
+ expect(diffSettingRows(["m:s.k1"], new Set(["m:s.k1"]))).toEqual([]);
168
+ });
169
+ });
170
+
171
+ describe("迁移账本 diffLedger", () => {
172
+ it("未应用 → error(not_applied)", () => {
173
+ expect(diffLedger([{ module: "m", version: 3 }], new Map([["m", null]]))).toEqual([{ id: "ledger", severity: "error", subject: "m", advise_key: "lifecycle.verify.ledger.not_applied" }]);
174
+ });
175
+ it("落后 → error(stale)", () => {
176
+ expect(diffLedger([{ module: "m", version: 3 }], new Map([["m", 2]]))).toEqual([{ id: "ledger", severity: "error", subject: "m", advise_key: "lifecycle.verify.ledger.stale" }]);
177
+ });
178
+ it("齐 → 空", () => {
179
+ expect(diffLedger([{ module: "m", version: 3 }], new Map([["m", 3]]))).toEqual([]);
180
+ });
181
+ });
182
+
183
+ // ============ manifest 声明提取 ============
184
+ describe("manifest 声明提取", () => {
185
+ it("declaredSettingsOf 摊平 groups[].items[].key", () => {
186
+ const m = { settings: { groups: [{ group: "g", items: [{ key: "m:s.k1" }, { key: "m:s.k2" }] }, { group: "g2", items: [{ key: "m:s.k3" }] }] } };
187
+ expect(declaredSettingsOf(m).sort()).toEqual(["m:s.k1", "m:s.k2", "m:s.k3"]);
188
+ });
189
+ it("无 settings / null → 空", () => {
190
+ expect(declaredSettingsOf({})).toEqual([]);
191
+ expect(declaredSettingsOf(null)).toEqual([]);
192
+ });
193
+ it("declaredMigrationsOf 取最高版本,无文件 → 空", () => {
194
+ expect(declaredMigrationsOf(["0001__a.sql", "0003__c.sql", "0002__b.sql"], "m")).toEqual([{ module: "m", version: 3 }]);
195
+ expect(declaredMigrationsOf([], "m")).toEqual([]);
196
+ });
197
+ });
198
+
199
+ // ============ 端点编排 ============
200
+ let dbo: any;
201
+ let root: string;
202
+ let last: { status: number, msg?: string, data?: unknown } | null;
203
+
204
+ // ============ P4-2 web 入口 integrity(diffWebIntegrity)============
205
+ describe("P4-2 深检 web 入口 integrity(diffWebIntegrity)", () => {
206
+ const distRoot = () => join(root, "dist");
207
+ const entry = "modules/demo/0.1.0/main.js";
208
+ async function seedEntry(bytes: Uint8Array) {
209
+ const p = join(distRoot(), entry);
210
+ await fsp.mkdir(p.slice(0, p.lastIndexOf("/")), { recursive: true });
211
+ await fsp.writeFile(p, bytes);
212
+ }
213
+ it("声明 integrity 与本地资产 sha384 相符 → 不打项", async () => {
214
+ const bytes = new TextEncoder().encode("console.log('demo')");
215
+ await seedEntry(bytes);
216
+ const integrity = `sha384-${sha384B64(bytes)}`;
217
+ expect(await diffWebIntegrity([{ name: "demo", entry, integrity }], distRoot(), sha384B64)).toEqual([]);
218
+ });
219
+ it("integrity 不符(被篡改/损坏)→ error(integrity_mismatch)", async () => {
220
+ const bytes = new TextEncoder().encode("console.log('demo')");
221
+ await seedEntry(bytes);
222
+ const items = await diffWebIntegrity([{ name: "demo", entry, integrity: "sha384-WRONG" }], distRoot(), sha384B64);
223
+ expect(items).toEqual([{ id: "web", severity: "error", subject: entry, advise_key: "lifecycle.verify.web.integrity_mismatch" }]);
224
+ });
225
+ it("缺失 integrity 声明 → info(no_integrity),非阻断", async () => {
226
+ await seedEntry(new TextEncoder().encode("x"));
227
+ expect(await diffWebIntegrity([{ name: "demo", entry }], distRoot(), sha384B64)).toEqual([{ id: "web", severity: "info", subject: "demo", advise_key: "lifecycle.verify.web.no_integrity" }]);
228
+ });
229
+ it("无 entry 条目 → info(no_entry),非阻断", async () => {
230
+ expect(await diffWebIntegrity([{ name: "demo" }], distRoot(), sha384B64)).toEqual([{ id: "web", severity: "info", subject: "*", advise_key: "lifecycle.verify.web.no_entry" }]);
231
+ });
232
+ it("entry 资产缺失(未落位)→ info(asset_missing),非阻断", async () => {
233
+ expect(await diffWebIntegrity([{ name: "demo", entry, integrity: "sha384-abc" }], distRoot(), sha384B64)).toEqual([{ id: "web", severity: "info", subject: entry, advise_key: "lifecycle.verify.web.asset_missing" }]);
234
+ });
235
+ });
236
+ const fetchCalls: string[] = [];
237
+ const fetchInits: unknown[] = [];
238
+
239
+ function okFetch() {
240
+ return async (url: string, init?: unknown) => {
241
+ fetchCalls.push(url);
242
+ fetchInits.push(init);
243
+ return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
244
+ };
245
+ }
246
+ function statusFetch(status: number) {
247
+ return async () => ({ ok: false, status, arrayBuffer: async () => new ArrayBuffer(0) });
248
+ }
249
+
250
+ beforeEach(async () => {
251
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-verify-"));
252
+ g.fs = nodeFsFace();
253
+ dbo = installDb();
254
+ dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
255
+ g.yaml = { parse: async (t: string) => JSON.parse(t) };
256
+ g.fetch = okFetch();
257
+ last = null;
258
+ fetchCalls.length = 0;
259
+ fetchInits.length = 0;
260
+ });
261
+
262
+ afterEach(async () => {
263
+ g.fs = undefined;
264
+ g.http = undefined;
265
+ g.json = undefined;
266
+ g.yaml = undefined;
267
+ g.fetch = undefined;
268
+ g.db = undefined;
269
+ await fsp.rm(root, { recursive: true, force: true });
270
+ });
271
+
272
+ function setHttp(over: Record<string, unknown>) {
273
+ g.http = { user: { id: 1 }, query: { name: "demo" }, headers: { host: "localhost:9778" }, ...over };
274
+ g.json = {
275
+ ok: (data: unknown) => { last = { status: 200, data }; },
276
+ fail: (status: number, msg: string) => { last = { status, msg }; },
277
+ };
278
+ }
279
+
280
+ function makeEnv() {
281
+ return { ...solutionsEnv(), sharedRoot: root, statePath: join(root, "state.yaml") };
282
+ }
283
+
284
+ describe("深检端点门(admin 域)", () => {
285
+ it("未登录 → 401", async () => {
286
+ setHttp({ user: undefined });
287
+ await createSolutionsApi(makeEnv()).verify.post();
288
+ expect(last?.status).toBe(401);
289
+ });
290
+ it("非 admin → 403(拒权不泄露存在性)", async () => {
291
+ setHttp({ user: { id: 2 } });
292
+ await createSolutionsApi(makeEnv()).verify.post();
293
+ expect(last?.status).toBe(403);
294
+ });
295
+ it("未装方案名 → 404(端点门)", async () => {
296
+ setHttp({});
297
+ await createSolutionsApi(makeEnv()).verify.post();
298
+ expect(last?.status).toBe(404);
299
+ });
300
+ });
301
+
302
+ describe("深检端点编排(四项)", () => {
303
+ it("全绿:路由 200 + 无声明权限/设置/迁移 + trusted → ok=true, checks=[]", async () => {
304
+ dbo.prepare("INSERT INTO sys_solution_route (path, module, category, \"order\", title_key, required_permission, enabled, deprecated) VALUES (?,?,?,?,?,?,?,0)").run("/api/demo", "demo", "main", 200, "demo:menu", null, 1);
305
+ // trusted 状态:无 trust 项、无检查项 → 干净报告(checks=[])
306
+ await fsp.writeFile(join(root, "state.yaml"), "solutions:\n demo:\n version: \"0.1.0\"\n enabled: true\n installedAt: 0\n trust_state: trusted\n");
307
+ setHttp({});
308
+ await createSolutionsApi(makeEnv()).verify.post();
309
+ expect(last?.status).toBe(200);
310
+ const data = last?.data as { ok: boolean, checks: unknown[], advise: string[] };
311
+ expect(data.ok).toBe(true);
312
+ expect(data.checks).toEqual([]);
313
+ expect(data.advise).toEqual([]);
314
+ // 探针用同源绝对 URL(SSRF 钉①②)
315
+ expect(fetchCalls[0]).toBe("http://localhost:9778/api/demo");
316
+ });
317
+
318
+ it("路由 404 → routes error 项,ok=false", async () => {
319
+ dbo.prepare("INSERT INTO sys_solution_route (path, module, category, \"order\", title_key, required_permission, enabled, deprecated) VALUES (?,?,?,?,?,?,?,0)").run("/api/demo", "demo", "main", 200, "demo:menu", null, 1);
320
+ g.fetch = statusFetch(404);
321
+ setHttp({});
322
+ await createSolutionsApi(makeEnv()).verify.post();
323
+ const data = last?.data as { ok: boolean, checks: Array<{ id: string, severity: string }> };
324
+ expect(data.ok).toBe(false);
325
+ expect(data.checks.some(c => c.id === "routes" && c.severity === "error")).toBe(true);
326
+ });
327
+
328
+ it("四项全触发(manifest 声明 vs DB 实有):rbac/settings/ledger 各 error", async () => {
329
+ // 路由声明权限点 demo.p(sys_permission 无 → rbac 缺失)
330
+ dbo.prepare("INSERT INTO sys_solution_route (path, module, category, \"order\", title_key, required_permission, enabled, deprecated) VALUES (?,?,?,?,?,?,?,0)").run("/api/demo", "demo", "main", 200, "demo:menu", "demo.p", 1);
331
+ // sys_setting 仅 demo:s.k1(manifest 声明 demo:s.k2 → settings 缺失)
332
+ dbo.prepare("INSERT INTO sys_setting (key, module, \"group\", type, value, default_value, label_key, options, secret, deprecated) VALUES (?,?,?,?,?,?,?,?,?,0)").run("demo:s.k1", "demo", "g", "string", "", "", "demo:settings.g.k1", null, 0);
333
+ // state(parseState 为严格逐行解析,非通用 YAML)+ manifest + migrations 落盘
334
+ await fsp.writeFile(join(root, "state.yaml"), "solutions:\n demo:\n version: \"0.1.0\"\n enabled: true\n installedAt: 0\n");
335
+ await fsp.mkdir(join(root, "demo-0.1.0", "migrations"), { recursive: true });
336
+ await fsp.writeFile(join(root, "demo-0.1.0", "manifest.yaml"), JSON.stringify({ settings: { groups: [{ group: "g", items: [{ key: "demo:s.k2" }] }] } }));
337
+ await fsp.writeFile(join(root, "demo-0.1.0", "migrations", "0003__seed.sql"), "");
338
+ setHttp({});
339
+ await createSolutionsApi(makeEnv()).verify.post();
340
+ const data = last?.data as { ok: boolean, checks: Array<{ id: string, subject: string }> };
341
+ expect(data.ok).toBe(false);
342
+ const ids = data.checks.map(c => c.id);
343
+ expect(ids).toContain("rbac");
344
+ expect(ids).toContain("settings");
345
+ expect(ids).toContain("ledger");
346
+ expect(data.checks.find(c => c.id === "rbac")?.subject).toBe("demo.p");
347
+ expect(data.checks.find(c => c.id === "settings")?.subject).toBe("demo:s.k2");
348
+ expect(data.checks.find(c => c.id === "ledger")?.subject).toBe("demo");
349
+ });
350
+
351
+ it("P4-2 web 入口 integrity 不符 → web error 项,ok=false", async () => {
352
+ dbo.prepare("INSERT INTO sys_solution_route (path, module, category, \"order\", title_key, required_permission, enabled, deprecated) VALUES (?,?,?,?,?,?,?,0)").run("/api/demo", "demo", "main", 200, "demo:menu", null, 1);
353
+ const mjPath = join(root, "dist", "modules.json");
354
+ const entry = "modules/demo/0.1.0/main.js";
355
+ await fsp.mkdir(join(root, "dist"), { recursive: true });
356
+ await fsp.writeFile(mjPath, JSON.stringify([{ name: "demo", entry, integrity: "sha384-WRONG" }]));
357
+ await fsp.mkdir(join(root, "dist", "modules", "demo", "0.1.0"), { recursive: true });
358
+ await fsp.writeFile(join(root, "dist", "modules", "demo", "0.1.0", "main.js"), "console.log('demo')");
359
+ setHttp({});
360
+ await createSolutionsApi({ ...makeEnv(), modulesJsonPath: mjPath }).verify.post();
361
+ const data = last?.data as { ok: boolean, checks: Array<{ id: string, severity: string, subject: string }> };
362
+ expect(data.ok).toBe(false);
363
+ const web = data.checks.find(c => c.id === "web");
364
+ expect(web?.severity).toBe("error");
365
+ expect(web?.subject).toBe(entry);
366
+ expect(web?.advise_key).toBe("lifecycle.verify.web.integrity_mismatch");
367
+ });
368
+
369
+ it("P4-2 web 入口 integrity 相符 → 不打 web 项(其余全绿则 ok=true)", async () => {
370
+ dbo.prepare("INSERT INTO sys_solution_route (path, module, category, \"order\", title_key, required_permission, enabled, deprecated) VALUES (?,?,?,?,?,?,?,0)").run("/api/demo", "demo", "main", 200, "demo:menu", null, 1);
371
+ const mjPath = join(root, "dist", "modules.json");
372
+ const entry = "modules/demo/0.1.0/main.js";
373
+ const bytes = new TextEncoder().encode("console.log('demo')");
374
+ const integrity = `sha384-${sha384B64(bytes)}`;
375
+ await fsp.mkdir(join(root, "dist"), { recursive: true });
376
+ await fsp.writeFile(mjPath, JSON.stringify([{ name: "demo", entry, integrity }]));
377
+ await fsp.mkdir(join(root, "dist", "modules", "demo", "0.1.0"), { recursive: true });
378
+ await fsp.writeFile(join(root, "dist", "modules", "demo", "0.1.0", "main.js"), bytes);
379
+ setHttp({});
380
+ await createSolutionsApi({ ...makeEnv(), modulesJsonPath: mjPath }).verify.post();
381
+ const data = last?.data as { ok: boolean, checks: Array<{ id: string }> };
382
+ expect(data.ok).toBe(true);
383
+ expect(data.checks.some(c => c.id === "web")).toBe(false);
384
+ });
385
+ });
@@ -0,0 +1,290 @@
1
+ // BDD:P2-8 深检报告结构 + 留存 N=10 + 可信性项(trust_state)+ i18n 降级项 + 设值不进报告。
2
+ // 纯模块单测(verify-report.ts)+ 端点编排(persist-on-POST / GET verify/last / 降级项 / secret 不泄漏)。
3
+ /* eslint-disable test/prefer-lowercase-title -- 端点动词/路径是验收锚点,保留大写(verify-checks.spec 同约定) */
4
+ import type { FsFace } from "../../src/_platform/_lifecycle/atomic";
5
+ import type { VerifyReport } from "../../src/_platform/_lifecycle/verify-report";
6
+ import * as fsp from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+ import {
11
+ getLastReport,
12
+ persistReport,
13
+ VERIFY_REPORTS_KEEP,
14
+ verifyReportsDirOf,
15
+ } from "../../src/_platform/_lifecycle/verify-report";
16
+ import { createSolutionsApi, solutionsEnv } from "../../src/_platform/solutions/api";
17
+ import { installDb } from "../fixture";
18
+
19
+ const g = globalThis as any;
20
+
21
+ function nodeFsFace(): FsFace {
22
+ return {
23
+ writeFile: (path, data) => fsp.writeFile(path, data),
24
+ readFile: path => fsp.readFile(path),
25
+ mkdir: (path, opts) => fsp.mkdir(path, opts),
26
+ remove: (path, opts) => fsp.rm(path, opts),
27
+ rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
28
+ copyFile: (src, dest) => fsp.copyFile(src, dest),
29
+ readdir: path => fsp.readdir(path),
30
+ };
31
+ }
32
+
33
+ // 每用例独立临时根;report 目录随用随建(atomicWriteText 会 mkdir)。
34
+ let root: string;
35
+ let reportsDir: string;
36
+ async function freshRoot(): Promise<void> {
37
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-vr-"));
38
+ reportsDir = join(root, "verify-reports");
39
+ }
40
+ async function cleanupRoot(): Promise<void> {
41
+ await fsp.rm(root, { recursive: true, force: true });
42
+ }
43
+
44
+ // ============ verify-report 纯模块 ============
45
+ describe("verify-report 纯模块", () => {
46
+ beforeEach(async () => {
47
+ await freshRoot();
48
+ g.fs = nodeFsFace();
49
+ });
50
+ afterEach(async () => {
51
+ g.fs = undefined;
52
+ await cleanupRoot();
53
+ });
54
+
55
+ describe("verifyReportsDirOf 目录推导", () => {
56
+ it("默认随 statePath 同目录 verify-reports", () => {
57
+ expect(verifyReportsDirOf({ statePath: "modules/.solutions/state.yaml" })).toBe("modules/.solutions/verify-reports");
58
+ });
59
+ it("显式 verifyReportsDir 优先", () => {
60
+ expect(verifyReportsDirOf({ statePath: "x/state.yaml", verifyReportsDir: "/custom" })).toBe("/custom");
61
+ });
62
+ });
63
+
64
+ describe("persist + getLast 往返", () => {
65
+ const report: VerifyReport = { ok: true, checks: [], advise: [] };
66
+ it("落盘后可取回", async () => {
67
+ await persistReport(reportsDir, "demo", report, 1000);
68
+ expect(await getLastReport(reportsDir, "demo")).toEqual(report);
69
+ });
70
+ it("目录缺失 → null(不炸)", async () => {
71
+ expect(await getLastReport(join(root, "nope"), "demo")).toBeNull();
72
+ });
73
+ });
74
+
75
+ describe("时间序保留 N=10(第 11 份挤掉第 1 份)", () => {
76
+ const report: VerifyReport = { ok: true, checks: [], advise: [] };
77
+ it("超 10 条删最旧,保留最新 10", async () => {
78
+ for (let i = 1; i <= 11; i++) {
79
+ await persistReport(reportsDir, "demo", report, i * 1000);
80
+ }
81
+ const files = (await fsp.readdir(reportsDir)).filter(n => /^\d+\.json$/.test(n)).sort((a, b) => Number(a.slice(0, -5)) - Number(b.slice(0, -5)));
82
+ expect(files.length).toBe(VERIFY_REPORTS_KEEP); // 10
83
+ expect(files[0]).toBe("2000.json"); // 最早 1000 已被挤掉
84
+ expect(files[files.length - 1]).toBe("11000.json"); // 最新仍在
85
+ expect(files).not.toContain("1000.json");
86
+ expect(await getLastReport(reportsDir, "demo")).toEqual(report);
87
+ });
88
+ });
89
+
90
+ describe("getLast 按 name 取最新(多方案不串)", () => {
91
+ it("同 name 取最大 createdAt,跨 name 不混淆", async () => {
92
+ const a: VerifyReport = { ok: true, checks: [], advise: ["a"] };
93
+ const b: VerifyReport = { ok: true, checks: [], advise: ["b"] };
94
+ await persistReport(reportsDir, "demo", a, 1000);
95
+ await persistReport(reportsDir, "other", b, 2000);
96
+ await persistReport(reportsDir, "demo", a, 3000); // demo 第二份更新
97
+ expect((await getLastReport(reportsDir, "demo"))?.advise).toEqual(["a"]);
98
+ expect((await getLastReport(reportsDir, "other"))?.advise).toEqual(["b"]);
99
+ });
100
+ });
101
+
102
+ describe("损坏文件容错", () => {
103
+ const report: VerifyReport = { ok: true, checks: [], advise: [] };
104
+ it("报告目录内损坏 JSON / 非报告名文件不炸 getLast", async () => {
105
+ await persistReport(reportsDir, "demo", report, 1000);
106
+ await fsp.writeFile(join(reportsDir, "9999.json"), "{not json");
107
+ await fsp.writeFile(join(reportsDir, "notes.txt"), "x");
108
+ expect(await getLastReport(reportsDir, "demo")).toEqual(report);
109
+ });
110
+ });
111
+ });
112
+
113
+ // ============ 端点编排 ============
114
+ let dbo: any;
115
+ let last: { status: number, msg?: string, data?: unknown } | null;
116
+
117
+ function okFetch() {
118
+ return async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) });
119
+ }
120
+
121
+ function setHttp(over: Record<string, unknown> = {}) {
122
+ g.http = { user: { id: 1 }, query: { name: "demo" }, headers: { host: "localhost:9778" }, ...over };
123
+ g.json = {
124
+ ok: (data: unknown) => { last = { status: 200, data }; },
125
+ fail: (status: number, msg: string) => { last = { status, msg }; },
126
+ };
127
+ }
128
+
129
+ function makeEnv() {
130
+ return { ...solutionsEnv(), sharedRoot: root, statePath: join(root, "state.yaml"), verifyReportsDir: reportsDir };
131
+ }
132
+
133
+ async function insertRoute(perm: string | null) {
134
+ dbo.prepare("INSERT INTO sys_solution_route (path, module, category, \"order\", title_key, required_permission, enabled, deprecated) VALUES (?,?,?,?,?,?,?,0)")
135
+ .run("/api/demo", "demo", "main", 200, "demo:menu", perm, 1);
136
+ }
137
+
138
+ async function writeState(trustState?: string) {
139
+ const tail = trustState === undefined ? "" : `\n trust_state: ${trustState}`;
140
+ await fsp.writeFile(join(root, "state.yaml"), `solutions:\n demo:\n version: \"0.1.0\"\n enabled: true\n installedAt: 0${tail}\n`);
141
+ }
142
+
143
+ async function endpointBefore() {
144
+ await freshRoot();
145
+ g.fs = nodeFsFace();
146
+ dbo = installDb();
147
+ dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
148
+ g.yaml = { parse: async (t: string) => JSON.parse(t) };
149
+ g.fetch = okFetch();
150
+ last = null;
151
+ }
152
+ async function endpointAfter() {
153
+ g.fs = undefined;
154
+ g.http = undefined;
155
+ g.json = undefined;
156
+ g.yaml = undefined;
157
+ g.fetch = undefined;
158
+ g.db = undefined;
159
+ await cleanupRoot();
160
+ }
161
+
162
+ describe("深检可信性项(trust_state)", () => {
163
+ beforeEach(endpointBefore);
164
+ afterEach(endpointAfter);
165
+
166
+ it("trusted → 不打 trust 项,ok=true", async () => {
167
+ await insertRoute(null);
168
+ await writeState("trusted");
169
+ setHttp({});
170
+ await createSolutionsApi(makeEnv()).verify.post();
171
+ const data = last?.data as VerifyReport;
172
+ expect(data.ok).toBe(true);
173
+ expect(data.checks.find(c => c.id === "trust")).toBeUndefined();
174
+ });
175
+ it("untrusted → trust error 项(key 已吊销仍在跑,补位)", async () => {
176
+ await insertRoute(null);
177
+ await writeState("untrusted");
178
+ setHttp({});
179
+ await createSolutionsApi(makeEnv()).verify.post();
180
+ const data = last?.data as VerifyReport;
181
+ expect(data.ok).toBe(false);
182
+ expect(data.checks.find(c => c.id === "trust" && c.severity === "error")).toBeDefined();
183
+ });
184
+ it("absent(未写,P1 遗留)→ trust info 项,非阻断", async () => {
185
+ await insertRoute(null);
186
+ await writeState(undefined);
187
+ setHttp({});
188
+ await createSolutionsApi(makeEnv()).verify.post();
189
+ const data = last?.data as VerifyReport;
190
+ expect(data.ok).toBe(true);
191
+ expect(data.checks.find(c => c.id === "trust" && c.severity === "info")).toBeDefined();
192
+ });
193
+ it("absent(显式)→ trust info 项", async () => {
194
+ await insertRoute(null);
195
+ await writeState("absent");
196
+ setHttp({});
197
+ await createSolutionsApi(makeEnv()).verify.post();
198
+ const data = last?.data as VerifyReport;
199
+ expect(data.checks.find(c => c.id === "trust" && c.severity === "info")).toBeDefined();
200
+ });
201
+ });
202
+
203
+ describe("i18n 依赖项 + advise 待查清单", () => {
204
+ beforeEach(endpointBefore);
205
+ afterEach(endpointAfter);
206
+
207
+ it("有检查项 → 出现 i18n info 项(非阻断)且 advise 含其 key", async () => {
208
+ // 声明权限点 demo.p 但 sys_permission 为空 → rbac missing_perm error(触发 advise 清单)
209
+ await insertRoute("demo.p");
210
+ await writeState("trusted"); // 干净 trust,避免 error 干扰断言
211
+ setHttp({});
212
+ await createSolutionsApi(makeEnv()).verify.post();
213
+ const data = last?.data as VerifyReport;
214
+ const i18n = data.checks.find(c => c.id === "i18n");
215
+ expect(i18n).toBeDefined();
216
+ expect(i18n?.severity).toBe("info");
217
+ expect(data.advise).toContain("lifecycle.verify.rbac.missing_perm");
218
+ expect(data.advise).toContain("lifecycle.verify.i18n.dependency");
219
+ // advise 去重排序
220
+ expect(data.advise).toEqual([...new Set(data.advise)].sort());
221
+ });
222
+ it("全绿无检查项 → 无 i18n 项、无 advise", async () => {
223
+ await insertRoute(null);
224
+ await writeState("trusted");
225
+ setHttp({});
226
+ await createSolutionsApi(makeEnv()).verify.post();
227
+ const data = last?.data as VerifyReport;
228
+ expect(data.checks.find(c => c.id === "i18n")).toBeUndefined();
229
+ expect(data.advise).toEqual([]);
230
+ });
231
+ });
232
+
233
+ describe("报告落盘 + GET /api/_platform/solutions/verify/last", () => {
234
+ beforeEach(endpointBefore);
235
+ afterEach(endpointAfter);
236
+
237
+ it("POST 后 verify-reports 落盘,GET last 取回同内容", async () => {
238
+ await insertRoute(null);
239
+ await writeState("trusted");
240
+ setHttp({});
241
+ await createSolutionsApi(makeEnv()).verify.post();
242
+ expect(last?.status).toBe(200);
243
+ const files = (await fsp.readdir(reportsDir)).filter(n => /^\d+\.json$/.test(n));
244
+ expect(files.length).toBe(1);
245
+ setHttp({});
246
+ await createSolutionsApi(makeEnv()).verify.last.get();
247
+ expect(last?.status).toBe(200);
248
+ expect((last?.data as VerifyReport).ok).toBe(true);
249
+ });
250
+ it("GET last 无报告 → 404", async () => {
251
+ setHttp({});
252
+ await createSolutionsApi(makeEnv()).verify.last.get();
253
+ expect(last?.status).toBe(404);
254
+ });
255
+ it("GET last 未登录 → 401", async () => {
256
+ setHttp({ user: undefined });
257
+ await createSolutionsApi(makeEnv()).verify.last.get();
258
+ expect(last?.status).toBe(401);
259
+ });
260
+ });
261
+
262
+ describe("设值不进报告(含 secret 场景钉)", () => {
263
+ beforeEach(endpointBefore);
264
+ afterEach(endpointAfter);
265
+
266
+ it("secret 设置值绝不出现在报告任何字段", async () => {
267
+ await insertRoute(null);
268
+ // trusted + 声明两个设置(其一 secret),sys_setting 只装了 api_url → secret_token 缺失项(只报 key)
269
+ await writeState("trusted");
270
+ await fsp.mkdir(join(root, "demo-0.1.0"), { recursive: true });
271
+ await fsp.writeFile(join(root, "demo-0.1.0", "manifest.yaml"), JSON.stringify({
272
+ settings: { groups: [{ group: "g", items: [
273
+ { key: "demo:secret_token", secret: true },
274
+ { key: "demo:api_url" },
275
+ ] }] },
276
+ }));
277
+ dbo.prepare("INSERT INTO sys_setting (key, module, \"group\", type, value, default_value, label_key, options, secret, deprecated) VALUES (?,?,?,?,?,?,?,?,?,0)")
278
+ .run("demo:api_url", "demo", "g", "string", "https://internal.example.com/token=supersecret", "", "demo:s.api_url", null, 0);
279
+ setHttp({});
280
+ await createSolutionsApi(makeEnv()).verify.post();
281
+ const data = last?.data as VerifyReport;
282
+ // 缺失项确实存在,且只报 key
283
+ expect(data.checks.find(c => c.id === "settings" && c.subject === "demo:secret_token")).toBeDefined();
284
+ // 敏感值绝不进报告(含 secret 标记场景)
285
+ const serialized = JSON.stringify(data);
286
+ expect(serialized).not.toContain("supersecret");
287
+ expect(serialized).not.toContain("https://internal.example.com");
288
+ expect(serialized).not.toContain("token=supersecret");
289
+ });
290
+ });