@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,228 @@
1
+ import type { InstallContext } from "../../../templates/api/src/_platform/_lifecycle/steps/preflight";
2
+
3
+ // BDD:bundle 级 manifest.yaml 契约校验(P1-11,C2 规则构建列)。
4
+ // 规则真源:docs/prd/solution/202610171254-lifecycle-manifest-contract.md(C2-01~33 构建列,§4.1 矩阵);
5
+ // 期望真源:同目录 fixtures/(fixture-valid 零违规、fixture-invalid 七码齐报)。
6
+ // 实现纪律(契约 §0/夹具 README):收集全部违规后统一报出,不在首条违规停。
7
+ // 双端一致:同一夹具既跑本侧 validateContract,也跑 isolate 侧 preflight——两份实现由夹具钉住。
8
+ import { readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+
11
+ import { fileURLToPath } from "node:url";
12
+ import { describe, expect, it } from "vitest";
13
+ import { parse } from "yaml";
14
+ // 跨端引用(双端一致钉):被测实现同仓分居两运行时(计划 §1「两运行时取舍」),
15
+ // 测试直接互调,比「两套件各读同一夹具」更强——漂移即本文件红。
16
+ import { preflight } from "../../../templates/api/src/_platform/_lifecycle/steps/preflight";
17
+ import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, validateContract } from "../../manifest-contract";
18
+
19
+ const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
20
+ const readText = (p: string) => readFileSync(join(FIXTURES, p), "utf8");
21
+
22
+ const VALID = parse(readText("fixture-valid/manifest.yaml")) as Record<string, unknown>;
23
+ const INVALID = parse(readText("fixture-invalid/manifest.yaml")) as Record<string, unknown>;
24
+
25
+ /** fixture-invalid 期望违规码(夹具 README/EXPECTED-VIOLATIONS 逐字) */
26
+ const INVALID_CODES = ["C2-01", "C2-02", "C2-16", "C2-17", "C2-22", "C2-23", "C2-26"];
27
+
28
+ function clauseOf(violations: string[]): string[] {
29
+ return violations.map(v => /【([^】]+)】/.exec(v)?.[1] ?? "");
30
+ }
31
+
32
+ // ── 双端一致:同一夹具,Node validateContract 与 isolate preflight 互认 ──
33
+
34
+ describe("双端一致(夹具 §5:DRY 于夹具而非运行时)", () => {
35
+ it("fixture-valid:validateContract 零违规,preflight 也通过", async () => {
36
+ expect(validateContract(VALID)).toEqual([]);
37
+ const r = await preflight(makeCtx(structuredClone(VALID)));
38
+ expect(r.ok).toBe(true);
39
+ });
40
+
41
+ it("fixture-invalid:两端均拒绝,且 validateContract 报出七码全集", async () => {
42
+ const violations = validateContract(INVALID);
43
+ expect(violations.length).toBeGreaterThan(0);
44
+ expect(clauseOf(violations)).toEqual(expect.arrayContaining(INVALID_CODES));
45
+ const r = await preflight(makeCtx(structuredClone(INVALID)));
46
+ expect(r.ok).toBe(false);
47
+ for (const code of INVALID_CODES)
48
+ expect(r.message).toContain(code);
49
+ });
50
+ });
51
+
52
+ // ── 表驱动:每规则一拒例(单点变异合法夹具)+ 共享过例 ──
53
+
54
+ interface RuleCase {
55
+ rule: string
56
+ clause: string
57
+ /** 违规串中除编号外必须出现的关键词(防「编号出现但语义报错」的假绿) */
58
+ keyword: string
59
+ mutate: (m: any) => void
60
+ }
61
+
62
+ const RULES: RuleCase[] = [
63
+ { rule: "name 白名单(大写拒绝)", clause: "C2-01", keyword: "name", mutate: (m) => { m.name = "Demo"; } },
64
+ { rule: "name 保留名(market,R9)", clause: "C2-01", keyword: "保留名", mutate: (m) => { m.name = "market"; } },
65
+ { rule: "version 禁 +(oj 相容排除,附录 B-1)", clause: "C2-02", keyword: "version", mutate: (m) => { m.version = "1.0.0+build.5"; } },
66
+ { rule: "version 禁下划线(契约收紧)", clause: "C2-02", keyword: "version", mutate: (m) => { m.version = "1.0.0_beta"; } },
67
+ { rule: "title ≤100", clause: "C2-03", keyword: "title", mutate: (m) => { m.title = "x".repeat(101); } },
68
+ { rule: "summary 必填", clause: "C2-04", keyword: "summary", mutate: (m) => { delete m.summary; } },
69
+ { rule: "summary 必须单行", clause: "C2-04", keyword: "单行", mutate: (m) => { m.summary = "第一行\n第二行"; } },
70
+ { rule: "description ≤2000", clause: "C2-05", keyword: "description", mutate: (m) => { m.description = "x".repeat(2001); } },
71
+ { rule: "icon 语法(http(s)/ 绝对路径)", clause: "C2-06", keyword: "icon", mutate: (m) => { m.icon = "ftp://x/y.svg"; } },
72
+ { rule: "license SPDX 简化式", clause: "C2-07", keyword: "license", mutate: (m) => { m.license = "没有空格的中文许可证!"; } },
73
+ { rule: "homepage http(s)", clause: "C2-07", keyword: "homepage", mutate: (m) => { m.homepage = "example.com"; } },
74
+ { rule: "keywords 项语法", clause: "C2-07", keyword: "keywords", mutate: (m) => { m.keywords = ["合法", "ok2"]; } },
75
+ { rule: "keywords ≤10", clause: "C2-07", keyword: "keywords", mutate: (m) => { m.keywords = Array.from({ length: 11 }, (_, i) => `k${i}`); } },
76
+ { rule: "changelog 必填(R27)", clause: "C2-08", keyword: "changelog", mutate: (m) => { delete m.changelog; } },
77
+ { rule: "changelog 数组条目非空", clause: "C2-08", keyword: "changelog", mutate: (m) => { m.changelog = [""]; } },
78
+ { rule: "changelog ≤50 条", clause: "C2-08", keyword: "changelog", mutate: (m) => { m.changelog = Array.from({ length: 51 }, (_, i) => `v${i}`); } },
79
+ { rule: "peerRuntime 暂不支持算子(<= 构建期即拒)", clause: "C2-09", keyword: "范围算子", mutate: (m) => { m.peerRuntime = "<=0.1.0"; } },
80
+ { rule: "peerRuntime 语法(version 部分遵守 C2-02)", clause: "C2-09", keyword: "peerRuntime", mutate: (m) => { m.peerRuntime = ">=beta1"; } },
81
+ { rule: "peerOj 语法(P1 声明不强制,语法由构建期负责)", clause: "C2-10", keyword: "peerOj", mutate: (m) => { m.peerOj = "1.0.0+build"; } },
82
+ { rule: "peerFramework 语法", clause: "C2-10", keyword: "peerFramework", mutate: (m) => { m.peerFramework = ">=^0.2"; } },
83
+ { rule: "peerPlatform 语法", clause: "C2-11", keyword: "peerPlatform", mutate: (m) => { m.peerPlatform = "1.0.0_beta"; } },
84
+ { rule: "api.module 存在时必须 === name", clause: "C2-12", keyword: "api.module", mutate: (m) => { m.api.module = "other-solution"; } },
85
+ { rule: "api.prefix 语法", clause: "C2-13", keyword: "api.prefix", mutate: (m) => { m.api.prefix = "/api/Demo"; } },
86
+ { rule: "api.prefix 保留段(system)", clause: "C2-13", keyword: "保留名", mutate: (m) => { m.api.prefix = "/api/system"; } },
87
+ { rule: "api.tables 项语法", clause: "C2-12a", keyword: "api.tables", mutate: (m) => { m.api.tables = ["1Bad"]; } },
88
+ { rule: "api.deps 键语法", clause: "C2-12b", keyword: "api.deps", mutate: (m) => { m.api.deps = { Bad_Key: ">=0.1.0" }; } },
89
+ { rule: "api.deps 值暂不支持算子", clause: "C2-12b", keyword: "范围算子", mutate: (m) => { m.api.deps = { other: "<1.0.0" }; } },
90
+ { rule: "anonymous 越段(R8 段内收敛)", clause: "C2-26", keyword: "首段", mutate: (m) => { m.api.anonymous = ["system/health"]; } },
91
+ { rule: "anonymous 登录端点禁令(R8/S1)", clause: "C2-26", keyword: "登录", mutate: (m) => { m.api.anonymous = ["demo-solution/login"]; } },
92
+ { rule: "anonymous 语法(前导斜杠拒绝)", clause: "C2-26", keyword: "语法", mutate: (m) => { m.api.anonymous = ["/demo-solution/ping"]; } },
93
+ { rule: "web.route_prefix 保留段", clause: "C2-14", keyword: "保留名", mutate: (m) => { m.web.route_prefix = "/system"; } },
94
+ { rule: "web.entry 版本段必须 === version", clause: "C2-27", keyword: "web.entry", mutate: (m) => { m.web.entry = "modules/demo-solution/9.9.9/entry.js"; } },
95
+ { rule: "web.css 后缀与版本段", clause: "C2-27", keyword: "web.css", mutate: (m) => { m.web.css = ["modules/demo-solution/9.9.9/x.css"]; } },
96
+ { rule: "web.integrity 形态(sha384-64base64)", clause: "C2-28", keyword: "web.integrity", mutate: (m) => { m.web.integrity = "md5-AAAA"; } },
97
+ { rule: "permission code 强制 <name>. 前缀", clause: "C2-15", keyword: "code", mutate: (m) => { m.permissions[0].code = "other.view"; } },
98
+ { rule: "permission title_key 命名空间", clause: "C2-15", keyword: "title_key", mutate: (m) => { m.permissions[0].title_key = "other:perm.view"; } },
99
+ { rule: "role name <name>.<role>", clause: "C2-16", keyword: "role", mutate: (m) => { m.roles[0].name = "demo-solution.Admin"; } },
100
+ { rule: "role permissions 悬空引用", clause: "C2-16", keyword: "悬空", mutate: (m) => { m.roles[0].permissions = ["demo-solution.absent"]; } },
101
+ { rule: "路由 category 三值(D4,personal 已废弃)", clause: "C2-17", keyword: "category", mutate: (m) => { m.routes[0].category = "personal"; } },
102
+ { rule: "路由 path 语法(参数段拒绝)", clause: "C2-17", keyword: "path", mutate: (m) => { m.routes[0].path = "/demo-solution/:id"; } },
103
+ { rule: "路由 order 必填整数", clause: "C2-17", keyword: "order", mutate: (m) => { delete m.routes[0].order; } },
104
+ { rule: "required_permission 悬空(C2-21)", clause: "C2-21", keyword: "required_permission", mutate: (m) => { m.routes[1].required_permission = "demo-solution.absent"; } },
105
+ { rule: "设置组标识语法", clause: "C2-22", keyword: "设置组", mutate: (m) => { m.settings.groups[0].group = "Display"; } },
106
+ { rule: "设置组 label_key 必填", clause: "C2-22", keyword: "label_key", mutate: (m) => { delete m.settings.groups[0].label_key; } },
107
+ { rule: "设置组 items 非空", clause: "C2-22", keyword: "items", mutate: (m) => { m.settings.groups[0].items = []; } },
108
+ { rule: "设置键所有权 moduleOf(key)===name(R24)", clause: "C2-23", keyword: "所有权", mutate: (m) => { m.settings.groups[0].items[0].key = "framework.site_title"; } },
109
+ { rule: "设置项 type 枚举", clause: "C2-24", keyword: "type", mutate: (m) => { m.settings.groups[0].items[0].type = "varchar"; } },
110
+ { rule: "type=enum 缺 options", clause: "C2-24", keyword: "options", mutate: (m) => {
111
+ m.settings.groups[0].items[0].type = "enum";
112
+ delete m.settings.groups[0].items[0].options;
113
+ } },
114
+ { rule: "设置项 default 必须字符串", clause: "C2-24", keyword: "default", mutate: (m) => { m.settings.groups[0].items[0].default = 20; } },
115
+ { rule: "设置项 label_key 命名空间", clause: "C2-24", keyword: "label_key", mutate: (m) => { m.settings.groups[0].items[0].label_key = "framework:settings.x"; } },
116
+ { rule: "health 须为本方案路由或其子路径", clause: "C2-33", keyword: "路由", mutate: (m) => { m.health = "/other-thing"; } },
117
+ { rule: "archive Form A 禁 sha256(自引用悖论,附录 B-2)", clause: "C2-29", keyword: "sha256", mutate: (m) => { m.archive.sha256 = "a".repeat(64); } },
118
+ { rule: "archive Form A 禁 signature", clause: "C2-29", keyword: "signature", mutate: (m) => { m.archive.signature = { alg: "Ed25519" }; } },
119
+ { rule: "archive.file 命名", clause: "C2-29", keyword: "archive.file", mutate: (m) => { m.archive.file = "wrong.tgz"; } },
120
+ { rule: "顶层未知键拒绝(防拼写漂移静默失效)", clause: "§1", keyword: "未知", mutate: (m) => { m.visibility = "public"; } },
121
+ ];
122
+
123
+ describe("validateContract 规则表(每规则一拒例)", () => {
124
+ it("共享过例:未变异的 fixture-valid 零违规", () => {
125
+ expect(validateContract(VALID)).toEqual([]);
126
+ });
127
+
128
+ it("非对象 manifest:统一报出单条可读违规", () => {
129
+ for (const bad of [null, "x", 42, []]) {
130
+ const v = validateContract(bad);
131
+ expect(v.length).toBeGreaterThan(0);
132
+ expect(v[0]).toContain("manifest");
133
+ }
134
+ });
135
+
136
+ for (const c of RULES) {
137
+ it(`拒例【${c.clause}】${c.rule}`, () => {
138
+ const m = structuredClone(VALID);
139
+ c.mutate(m);
140
+ const violations = validateContract(m);
141
+ const hit = violations.find(v => v.includes(c.clause));
142
+ expect(hit, `期望包含【${c.clause}】的违规,实际:\n${violations.join("\n")}`).toBeTruthy();
143
+ expect(hit).toContain(c.keyword);
144
+ });
145
+ }
146
+ });
147
+
148
+ // ── parseManifest ──
149
+
150
+ describe("parseManifest", () => {
151
+ it("合法 yaml → ok:true 且返回对象", () => {
152
+ const r = parseManifest(readText("fixture-valid/manifest.yaml"));
153
+ expect(r.ok).toBe(true);
154
+ if (r.ok)
155
+ expect(r.manifest.name).toBe("demo-solution");
156
+ });
157
+
158
+ it("非法 yaml 语法 → ok:false 可读错误(不含堆栈噪音)", () => {
159
+ const r = parseManifest("name: [unclosed");
160
+ expect(r.ok).toBe(false);
161
+ if (!r.ok)
162
+ expect(r.error).toContain("manifest.yaml");
163
+ });
164
+
165
+ it("顶层非映射(数组/标量)→ ok:false", () => {
166
+ for (const bad of ["- a\n- b\n", "42\n", "null\n"]) {
167
+ const r = parseManifest(bad);
168
+ expect(r.ok).toBe(false);
169
+ }
170
+ });
171
+ });
172
+
173
+ // ── 文件面契约(构建期专属执行点;纯内容入参,fs 在 bundle.ts)──
174
+
175
+ describe("checkTablesAgainstSchema(C2-12a:构建 ✅ 对照 schema.yaml)", () => {
176
+ const SCHEMA = readText("fixture-valid/api/src/demo-solution/schema.yaml");
177
+
178
+ it("fixture-valid tables [note] 与 schema.yaml 表集一致 → 零违规", () => {
179
+ expect(checkTablesAgainstSchema(structuredClone(VALID), SCHEMA)).toEqual([]);
180
+ });
181
+
182
+ it("tables 与 schema.yaml 表集不一致 → C2-12a 且指明差额", () => {
183
+ const m = structuredClone(VALID);
184
+ (m.api as any).tables = ["note", "ghost"];
185
+ const v = checkTablesAgainstSchema(m, SCHEMA);
186
+ expect(v[0]).toContain("C2-12a");
187
+ expect(v[0]).toContain("ghost");
188
+ });
189
+ });
190
+
191
+ describe("checkLocalesComplete(C2-25:构建期缺 key fail-fast)", () => {
192
+ const ZH = readText("fixture-valid/web/src/demo-solution/locales/zh-CN.json");
193
+ const EN = readText("fixture-valid/web/src/demo-solution/locales/en-US.json");
194
+
195
+ it("fixture-valid 双语齐备 → 零违规", () => {
196
+ expect(checkLocalesComplete(structuredClone(VALID), ZH, EN)).toEqual([]);
197
+ });
198
+
199
+ it("zh-CN 缺引用 key → C2-25 且指明 key 与语言", () => {
200
+ const zh = JSON.parse(ZH) as any;
201
+ delete zh.settings.group.display;
202
+ const v = checkLocalesComplete(structuredClone(VALID), JSON.stringify(zh), EN);
203
+ expect(v[0]).toContain("C2-25");
204
+ expect(v[0]).toContain("zh-CN");
205
+ expect(v[0]).toContain("demo-solution:settings.group.display");
206
+ });
207
+
208
+ it("en-US 缺引用 key → C2-25 指明 en-US", () => {
209
+ const en = JSON.parse(EN) as any;
210
+ delete en.perm.view;
211
+ const v = checkLocalesComplete(structuredClone(VALID), ZH, JSON.stringify(en));
212
+ expect(v[0]).toContain("C2-25");
213
+ expect(v[0]).toContain("en-US");
214
+ });
215
+ });
216
+
217
+ // ── isolate 侧 preflight 复用助手(与 templates/api/tests/lifecycle/preflight.spec.ts 同构)──
218
+
219
+ function makeCtx(manifest: unknown): InstallContext {
220
+ return {
221
+ manifest,
222
+ name: typeof (manifest as any)?.name === "string" ? (manifest as any).name : "demo-solution",
223
+ version: typeof (manifest as any)?.version === "string" ? (manifest as any).version : "0.1.0",
224
+ state: { read: () => ({ solutions: {} }), write: async () => {} },
225
+ hostVersions: { "@oj-module/runtime": "0.1.80" },
226
+ hostApiVersion: "0.1.0", // C2-11 真源(= market/api.ts API_VERSION;与 fixture-valid peerPlatform '>=0.1.0' 配套)
227
+ };
228
+ }
@@ -0,0 +1,80 @@
1
+ // publish 命令 spec(P3-5/6/7):重算索引 + detached .sig 签名写回 + 离线自检。
2
+ // 纯 Node 侧(与 ojm sign 同签名原语 lifecycle/sign.ts),验证侧用 verifyCanonical 复核。
3
+ import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import { indexCanonical, publishCommand, recomputeIndex, signIndex } from "../../publish";
8
+ import { verifyCanonical } from "../sign";
9
+ import { defaultTestKey } from "./helpers";
10
+
11
+ const BUNDLES = `solutions:
12
+ - name: demo-solution
13
+ latest: 0.1.0
14
+ versions:
15
+ - version: 0.1.0
16
+ file: demo-solution-0.1.0.tgz
17
+ `;
18
+ const MANIFESTS = `- name: demo-solution
19
+ version: 0.1.0
20
+ author: acme
21
+ changelog: initial
22
+ `;
23
+
24
+ let dir: string;
25
+ let keyPath: string;
26
+
27
+ beforeEach(() => {
28
+ dir = mkdtempSync(join(tmpdir(), "ojm-publish-"));
29
+ writeFileSync(join(dir, "bundles.yaml"), BUNDLES);
30
+ writeFileSync(join(dir, "manifests.yaml"), MANIFESTS);
31
+ keyPath = join(dir, "key.pem");
32
+ writeFileSync(keyPath, defaultTestKey().privateKeyPem);
33
+ });
34
+
35
+ afterEach(() => {
36
+ // 临时目录由 OS 回收(测试用 tmpdir,无需手动 rm)
37
+ });
38
+
39
+ describe("ojm publish 索引重算 + 签名(P3-5/6/7)", () => {
40
+ it("重算两份索引并产出 detached .sig,签名可被 verifyCanonical 复核通过", () => {
41
+ const res = publishCommand(dir, { key: keyPath, id: "test.publisher" });
42
+ expect(existsSync(res.bundlesSigPath)).toBe(true);
43
+ expect(existsSync(res.manifestsSigPath)).toBe(true);
44
+ expect(res.keyId).toBe("test.publisher");
45
+
46
+ const bundlesText = readFileSync(res.bundlesPath, "utf8");
47
+ const manifestsText = readFileSync(res.manifestsPath, "utf8");
48
+ const bundlesSig = JSON.parse(readFileSync(res.bundlesSigPath, "utf8"));
49
+ const manifestsSig = JSON.parse(readFileSync(res.manifestsSigPath, "utf8"));
50
+
51
+ expect(bundlesSig.alg).toBe("Ed25519");
52
+ expect(bundlesSig.key_id).toBe("test.publisher");
53
+ expect(bundlesSig.canon).toBe("v1");
54
+ expect(verifyCanonical(res.publicKeyHex, indexCanonical("bundles", bundlesText), bundlesSig.value)).toBe(true);
55
+ expect(verifyCanonical(res.publicKeyHex, indexCanonical("manifests", manifestsText), manifestsSig.value)).toBe(true);
56
+ });
57
+
58
+ it("--check 离线自检通过(checkPassed === true)", () => {
59
+ const res = publishCommand(dir, { key: keyPath, id: "test.publisher", check: true });
60
+ expect(res.checkPassed).toBe(true);
61
+ });
62
+
63
+ it("重算使索引确定性化(同内容重排后 sha256 稳定可签)", () => {
64
+ const a = recomputeIndex("x: 1\ny: 2\n");
65
+ const b = recomputeIndex("y: 2\nx: 1\n");
66
+ expect(a).toBe(b);
67
+ // 重算文本可被 signIndex 正常签出五字段
68
+ const sig = signIndex("bundles", a, defaultTestKey().privateKeyPem, "test.publisher");
69
+ expect(verifyCanonical(defaultTestKey().publicKeyHex, indexCanonical("bundles", a), sig.value)).toBe(true);
70
+ });
71
+
72
+ it("缺索引文件 → 抛错(不发半成品 .sig)", () => {
73
+ unlinkSync(join(dir, "bundles.yaml"));
74
+ expect(() => publishCommand(dir, { key: keyPath, id: "test.publisher" })).toThrow(/须含 bundles\.yaml 与 manifests\.yaml/);
75
+ });
76
+
77
+ it("私钥缺失 → 抛错(fail-closed,不静默)", () => {
78
+ expect(() => publishCommand(dir, { key: join(dir, "nope.pem"), id: "test.publisher" })).toThrow(/私钥文件不存在/);
79
+ });
80
+ });
@@ -0,0 +1,100 @@
1
+ // 签名链夹具 CLI 面断言(P2-0,契约 v1.3 §2.11/§2.12 + 附录 A.2)。
2
+ // 锚点语义:夹具是双端唯一共同真源——本 spec 钉「规范串逐字 + 指纹值 + 验签结果」,
3
+ // 与 P2-1 sign.ts(Node 对拍)、P2-3 steps/verify.ts(isolate 面 verifyEd25519)三方同语义。
4
+ // 六码(sig_missing/sig_alg/sig_key/tampered/unknown_key/sig_format)的 isolate 实现与
5
+ // TOFU 全链属 P2-3 验收;此处只钉夹具本身与 Node 面计算。
6
+ import { Buffer } from "node:buffer";
7
+ import { createHash } from "node:crypto";
8
+ import { readFileSync } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { describe, expect, it } from "vitest";
13
+ import {
14
+ CANON,
15
+ canonicalString,
16
+ fingerprintOf,
17
+ FIXTURE_NAME,
18
+ FIXTURE_VERSION,
19
+ generate,
20
+ TRUSTED_KEY,
21
+ UNKNOWN_KEY,
22
+ verifyCanonicalB64,
23
+ } from "./fixtures/sig/gen-signed-fixtures.mjs";
24
+
25
+ const SIG_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "sig");
26
+ const tgzPath = path.join(SIG_DIR, `${FIXTURE_NAME}-${FIXTURE_VERSION}.tgz`);
27
+
28
+ interface Sig {
29
+ alg: string
30
+ key_id: string
31
+ public_key: string
32
+ value: string
33
+ canon: string
34
+ }
35
+
36
+ const readSig = (suffix: string): Sig => JSON.parse(readFileSync(`${tgzPath}${suffix}`, "utf8")) as Sig;
37
+
38
+ const HEX64 = /^[0-9a-f]{64}$/;
39
+ const STD_B64 = /^[A-Z0-9+/]+={0,2}$/i;
40
+ // 契约 §2.11 key_id 命名空间:<publisher>.<id> 全小写,禁裸通用名单段
41
+ const KEY_ID = /^[a-z][a-z0-9-]*\.[a-z0-9-]+$/;
42
+
43
+ describe("签名链夹具(P2-0)", () => {
44
+ const tgz = readFileSync(tgzPath);
45
+ const sha256 = createHash("sha256").update(tgz).digest("hex");
46
+ const canonical = canonicalString(FIXTURE_NAME, FIXTURE_VERSION, sha256);
47
+ const good = readSig(".sig");
48
+ const bad = readSig(".bad.sig");
49
+ const unknownSig = readSig(".unknown.sig");
50
+
51
+ it("规范串逐字 = <name>@<version>:<sha256>(契约 §2.11 格式表)", () => {
52
+ expect(canonical).toBe(`${FIXTURE_NAME}@${FIXTURE_VERSION}:${sha256}`);
53
+ expect(canonical).toMatch(/^demo-solution@0\.2\.0:[0-9a-f]{64}$/);
54
+ });
55
+
56
+ it(".sig 五字段齐备且形态合法(alg 恒 Ed25519 / canon 恒 v1)", () => {
57
+ for (const sig of [good, bad, unknownSig]) {
58
+ expect(sig.alg).toBe("Ed25519");
59
+ expect(sig.canon).toBe(CANON);
60
+ expect(sig.public_key).toMatch(HEX64);
61
+ expect(sig.value).toMatch(STD_B64);
62
+ expect(sig.value).not.toMatch(/[-_]/); // 禁 base64url(§2.11 编码约束)
63
+ expect(sig.key_id).toMatch(KEY_ID);
64
+ }
65
+ });
66
+
67
+ it("指纹 = sha256Hex(publicKey) 全 64 hex", () => {
68
+ expect(fingerprintOf(good.public_key)).toMatch(HEX64);
69
+ expect(fingerprintOf(unknownSig.public_key)).toMatch(HEX64);
70
+ expect(fingerprintOf(good.public_key)).not.toBe(fingerprintOf(unknownSig.public_key));
71
+ });
72
+
73
+ it("已签夹具验签通过;篡改 value / 错 key / 篡改包体均拒", () => {
74
+ expect(verifyCanonicalB64(good.public_key, canonical, good.value)).toBe(true);
75
+ expect(verifyCanonicalB64(bad.public_key, canonical, bad.value)).toBe(false); // tampered
76
+ expect(verifyCanonicalB64(good.public_key, canonical, unknownSig.value)).toBe(false); // 错 key
77
+ const tampered = Buffer.from(tgz);
78
+ tampered[100] ^= 0xFF;
79
+ const sha2 = createHash("sha256").update(tampered).digest("hex");
80
+ expect(verifyCanonicalB64(good.public_key, canonicalString(FIXTURE_NAME, FIXTURE_VERSION, sha2), good.value)).toBe(false);
81
+ });
82
+
83
+ it("未知 key 夹具是真实签名但 key_id 不在信任库(TOFU 场景锚点)", () => {
84
+ expect(unknownSig.key_id).toBe(UNKNOWN_KEY.key_id);
85
+ expect(unknownSig.public_key).toBe(UNKNOWN_KEY.public_key_hex);
86
+ expect(verifyCanonicalB64(unknownSig.public_key, canonical, unknownSig.value)).toBe(true);
87
+ expect(unknownSig.key_id).not.toBe(TRUSTED_KEY.key_id);
88
+ });
89
+
90
+ it("生成器确定性:再生到 tmp 与提交产物字节一致", () => {
91
+ const out = path.join(os.tmpdir(), `ojm-sig-fixtures-${process.pid}`);
92
+ const r = generate(out);
93
+ expect(r.sha256).toBe(sha256);
94
+ expect(r.canonical).toBe(canonical);
95
+ expect(readFileSync(r.paths.tgz).equals(tgz)).toBe(true);
96
+ expect(readFileSync(r.paths.good, "utf8")).toBe(readFileSync(`${tgzPath}.sig`, "utf8"));
97
+ expect(readFileSync(r.paths.bad, "utf8")).toBe(readFileSync(`${tgzPath}.bad.sig`, "utf8"));
98
+ expect(readFileSync(r.paths.unknown, "utf8")).toBe(readFileSync(`${tgzPath}.unknown.sig`, "utf8"));
99
+ });
100
+ });
@@ -0,0 +1,131 @@
1
+ // 签名纯函数 spec(P2-1,契约 v1.3 §2.11 / C2-34~36)。BDD 五场景 + 夹具对拍锚点。
2
+ import { Buffer } from "node:buffer";
3
+ import { createHash, createPublicKey, verify as nodeVerify } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { describe, expect, it } from "vitest";
8
+ import { generateKeyPair } from "../keygen";
9
+ import { CANON, canonicalString, exportRawPublicKeyHex, fingerprintOfPublicKey, SIG_ALG, signCanonical, verifyCanonical } from "../sign";
10
+ import { FIXTURE_NAME, FIXTURE_VERSION, TRUSTED_KEY, UNKNOWN_KEY } from "./fixtures/sig/gen-signed-fixtures.mjs";
11
+ import { defaultTestKey, signedArchiveFor } from "./helpers";
12
+
13
+ const SHA_A = "a".repeat(64);
14
+
15
+ describe("规范串(C2-34)", () => {
16
+ it("拼接式逐字 <name>@<version>:<sha256>(契约 §2.11 格式表)", () => {
17
+ expect(canonicalString("demo-solution", "0.2.0", SHA_A)).toBe(`demo-solution@0.2.0:${SHA_A}`);
18
+ });
19
+
20
+ it("sha256 形态非法不拦截(规范化属调用方结构校验序,P2-3)", () => {
21
+ // 纯函数只负责拼接;sha256 合法性由 verify 段比对时保证(C2-29 fail-closed)
22
+ expect(canonicalString("a", "1", "short")).toBe("a@1:short");
23
+ });
24
+ });
25
+
26
+ describe("签验往返与对拍", () => {
27
+ const key = generateKeyPair();
28
+ const canonical = canonicalString("demo-solution", "0.1.0", SHA_A);
29
+
30
+ it("sign → verifyCanonical 往返 true;篡改 canonical 一字符 → false", () => {
31
+ const sig = signCanonical(key.privateKeyPem, canonical);
32
+ expect(verifyCanonical(key.publicKeyHex, canonical, sig)).toBe(true);
33
+ const tampered = `${canonical.slice(0, -1)}${canonical.at(-1) === "a" ? "b" : "a"}`;
34
+ expect(verifyCanonical(key.publicKeyHex, tampered, sig)).toBe(false);
35
+ });
36
+
37
+ it("node crypto.verify 对拍:同 key 同 canonical 同 value,结果一致", () => {
38
+ const sig = signCanonical(key.privateKeyPem, canonical);
39
+ const pub = createPublicKey({
40
+ key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(key.publicKeyHex, "hex")]),
41
+ format: "der",
42
+ type: "spki",
43
+ });
44
+ expect(nodeVerify(null, Buffer.from(canonical, "utf8"), pub, Buffer.from(sig, "base64"))).toBe(
45
+ verifyCanonical(key.publicKeyHex, canonical, sig),
46
+ );
47
+ expect(nodeVerify(null, Buffer.from(canonical, "utf8"), pub, Buffer.from(sig, "base64"))).toBe(true);
48
+ });
49
+
50
+ it("确定性:Ed25519 同 key 同 canonical 两次签名相同", () => {
51
+ expect(signCanonical(key.privateKeyPem, canonical)).toBe(signCanonical(key.privateKeyPem, canonical));
52
+ });
53
+
54
+ it("公钥导出与指纹:exportRawPublicKeyHex === generateKeyPair.publicKeyHex,指纹 64hex", () => {
55
+ expect(exportRawPublicKeyHex(key.privateKeyPem)).toBe(key.publicKeyHex);
56
+ expect(fingerprintOfPublicKey(key.publicKeyHex)).toMatch(/^[0-9a-f]{64}$/);
57
+ });
58
+ });
59
+
60
+ describe("编码面严格拒(C2-36,禁归一)", () => {
61
+ const key = generateKeyPair();
62
+ const canonical = canonicalString("demo-solution", "0.1.0", SHA_A);
63
+ const good = signCanonical(key.privateKeyPem, canonical);
64
+
65
+ it("value 含 -/_(base64url 变体)→ false", () => {
66
+ // 必须挑一把 base64 表示里**确实含 +/** 的签名再转 base64url:64 字节签名的
67
+ // base64 有 ~7% 概率不含 +/(88 字符各 2/64),此时 base64url 与原值只差被
68
+ // 抹掉的 padding,而 Node 的 base64 解码对缺 padding 宽容,会还原出同一串
69
+ // 字节使校验返回 true——用例以 ~7% 概率偶发失败(实测 2000 次 7.2%),
70
+ // 且那时「禁归一」的本意根本没被测到。换 key 直到形态真的不同。
71
+ let key = generateKeyPair();
72
+ let sig = signCanonical(key.privateKeyPem, canonical);
73
+ for (let i = 0; !/[+/]/.test(Buffer.from(sig, "base64").toString("base64")) && i < 64; i++) {
74
+ key = generateKeyPair();
75
+ sig = signCanonical(key.privateKeyPem, canonical);
76
+ }
77
+ const b64url = Buffer.from(sig, "base64").toString("base64url");
78
+ expect(b64url).toMatch(/[-_]/); // 形态确实是 base64url(-/_ 替换已发生)
79
+ expect(b64url).not.toBe(sig);
80
+ expect(verifyCanonical(key.publicKeyHex, canonical, b64url)).toBe(false);
81
+ });
82
+
83
+ it("public_key 非 64hex(63/65/PEM 串)→ false", () => {
84
+ expect(verifyCanonical(key.publicKeyHex.slice(1), canonical, good)).toBe(false);
85
+ expect(verifyCanonical(`${key.publicKeyHex}0`, canonical, good)).toBe(false);
86
+ expect(verifyCanonical("-----BEGIN PUBLIC KEY-----", canonical, good)).toBe(false);
87
+ });
88
+
89
+ it("value 非标准 base64(空串/含 !)→ false", () => {
90
+ expect(verifyCanonical(key.publicKeyHex, canonical, "")).toBe(false);
91
+ expect(verifyCanonical(key.publicKeyHex, canonical, `${good}!`)).toBe(false);
92
+ });
93
+
94
+ it("错 key 签名 → false", () => {
95
+ const other = generateKeyPair();
96
+ expect(verifyCanonical(other.publicKeyHex, canonical, good)).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe("p2-0 夹具对拍(双端三同一锚点)", () => {
101
+ const SIG_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "sig");
102
+ const tgz = readFileSync(path.join(SIG_DIR, `${FIXTURE_NAME}-${FIXTURE_VERSION}.tgz`));
103
+ const sha256 = createHash("sha256").update(tgz).digest("hex");
104
+ const canonical = canonicalString(FIXTURE_NAME, FIXTURE_VERSION, sha256);
105
+ const readSig = (suffix: string) => JSON.parse(readFileSync(path.join(SIG_DIR, `${FIXTURE_NAME}-${FIXTURE_VERSION}.tgz${suffix}`), "utf8")) as { key_id: string, public_key: string, value: string, canon: string, alg: string };
106
+
107
+ it("已签/篡改/未知 key 三态经 sign.ts 验签结果与生成器一致", () => {
108
+ const good = readSig(".sig");
109
+ const bad = readSig(".bad.sig");
110
+ const unknownSig = readSig(".unknown.sig");
111
+ expect([good.alg, good.canon]).toEqual([SIG_ALG, CANON]);
112
+ expect([good.key_id, good.public_key]).toEqual([TRUSTED_KEY.key_id, TRUSTED_KEY.public_key_hex]);
113
+ expect(verifyCanonical(good.public_key, canonical, good.value)).toBe(true);
114
+ expect(verifyCanonical(bad.public_key, canonical, bad.value)).toBe(false);
115
+ expect([unknownSig.key_id, unknownSig.public_key]).toEqual([UNKNOWN_KEY.key_id, UNKNOWN_KEY.public_key_hex]);
116
+ expect(verifyCanonical(unknownSig.public_key, canonical, unknownSig.value)).toBe(true);
117
+ });
118
+
119
+ it("signedArchiveFor 对夹具包体重签:验签过且指纹与公钥对应", () => {
120
+ const signed = signedArchiveFor(new Uint8Array(tgz), FIXTURE_NAME, FIXTURE_VERSION);
121
+ expect(signed.sig.canon).toBe(CANON);
122
+ expect(signed.sig.alg).toBe(SIG_ALG);
123
+ expect(signed.sig.public_key).toMatch(/^[0-9a-f]{64}$/);
124
+ expect(verifyCanonical(signed.publicKeyHex, canonical, signed.sig.value)).toBe(true);
125
+ expect(signed.fingerprint).toBe(fingerprintOfPublicKey(signed.publicKeyHex));
126
+ });
127
+
128
+ it("defaultTestKey 进程内稳定(同文件多次调用同 key)", () => {
129
+ expect(defaultTestKey().publicKeyHex).toBe(defaultTestKey().publicKeyHex);
130
+ });
131
+ });
@@ -0,0 +1,61 @@
1
+ // 真机冒烟(P1-13,skip-if-env-missing):对真实站点 file 源安装 fixture-valid 包,断言终态 done。
2
+ // 环境齐备才跑(OJM_SMOKE_URL / OJM_SMOKE_USER / OJM_SMOKE_PASS,站点需已有 superadmin 账号;
3
+ // 站点起停属部署层职责——P1-16 e2e 钉定生产口径,此处不拉起进程),缺省整体跳过,CI 不红。
4
+ import { cpSync, mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
9
+ import { buildBundle } from "../../bundle";
10
+ import { installCommand } from "../../install";
11
+ import { DEFAULT_SITE_URL } from "../../site";
12
+
13
+ const SMOKE_URL = process.env.OJM_SMOKE_URL;
14
+ const SMOKE_USER = process.env.OJM_SMOKE_USER ?? process.env.OJM_USER;
15
+ const SMOKE_PASS = process.env.OJM_SMOKE_PASS ?? process.env.OJM_PASS;
16
+ const smokeReady = SMOKE_URL !== undefined && SMOKE_USER !== undefined && SMOKE_PASS !== undefined;
17
+
18
+ const FIXTURE_VALID = join(dirname(fileURLToPath(import.meta.url)), "fixtures/fixture-valid");
19
+
20
+ let project: string;
21
+
22
+ beforeAll(() => {
23
+ project = mkdtempSync(join(tmpdir(), "ojm-smoke-proj-"));
24
+ cpSync(FIXTURE_VALID, project, { recursive: true });
25
+ });
26
+
27
+ afterEach(() => {
28
+ // 只清本测试的临时工程(站点侧安装产物不回滚——真机站点是外部环境)
29
+ if (project !== undefined)
30
+ rmSync(project, { recursive: true, force: true });
31
+ project = undefined as unknown as string;
32
+ });
33
+
34
+ describe.skipIf(!smokeReady)("真机冒烟(需 OJM_SMOKE_URL/OJM_SMOKE_USER/OJM_SMOKE_PASS)", () => {
35
+ it("file 源安装 fixture-valid 包 → 终态 done", { timeout: 600_000 }, async () => {
36
+ // 先出可安装 tgz(同 bundle.test.ts:预置已构建产物再 --bundle 组装)
37
+ const apiDist = join(project, "api/dist/demo-solution-0.1.0");
38
+ const { mkdirSync, writeFileSync } = await import("node:fs");
39
+ mkdirSync(join(apiDist, "ping"), { recursive: true });
40
+ // 迁移 SQL 随夹具 src 真源(0001__init.sql 建 note 表);B-11 硬验收:appliedSeq > 0 可断言
41
+ cpSync(join(FIXTURE_VALID, "api/src/demo-solution/migrations"), join(apiDist, "migrations"), { recursive: true });
42
+ // schema.yaml 同随夹具真源(列集与迁移 SQL 一致):oj migrate 在 apply 后跑 schema
43
+ // reconcile(migrate_cmd.rs migrate_one),声明缺列而实库多列 = Err——合成空列集会炸
44
+ writeFileSync(join(apiDist, "manifest.yaml"), "name: demo-solution\ndesc: manifest.yaml 契约全字段合法夹具\nversion: 0.1.0\ntables:\n - note\n");
45
+ cpSync(join(FIXTURE_VALID, "api/src/demo-solution/schema.yaml"), join(apiDist, "schema.yaml"));
46
+ writeFileSync(join(apiDist, "routes.js"), "export {};\n");
47
+ writeFileSync(join(apiDist, "ping/api.js"), "export async function ping() {}\n");
48
+ const webDist = join(project, "web/dist/modules/demo-solution/0.1.0");
49
+ mkdirSync(webDist, { recursive: true });
50
+ writeFileSync(join(webDist, "entry.js"), "export default {};\n");
51
+ writeFileSync(join(webDist, "demo-solution.css"), ".demo{}\n");
52
+ const { tgz } = await buildBundle(project, { out: join(project, "dist") });
53
+
54
+ const record = await installCommand(tgz, { url: SMOKE_URL ?? DEFAULT_SITE_URL, user: SMOKE_USER, pass: SMOKE_PASS });
55
+ expect(record.status).toBe("done");
56
+ expect(record.name).toBe("demo-solution");
57
+ expect(record.version).toBe("0.1.0");
58
+ // B-11 硬验收:迁移真实执行过(oj migrate applied 非空),非 web-only 空跑
59
+ expect(record.appliedSeq).toBeGreaterThan(0);
60
+ });
61
+ });