@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.
- package/devkit/README.md +14 -3
- package/devkit/SKILL.md +16 -4
- package/devkit/framework-dev.md +8 -6
- package/devkit/manual.md +243 -23
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +143 -8
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/args.ts +67 -0
- package/src/build.ts +11 -3
- package/src/bundle.ts +197 -0
- package/src/contract/__tests__/handler-scan.test.ts +82 -0
- package/src/contract/check.ts +2 -1
- package/src/contract/handler-scan.ts +32 -0
- package/src/deterministic-tar.ts +120 -0
- package/src/dev.ts +4 -2
- package/src/enable.ts +28 -0
- package/src/index.ts +63 -2
- package/src/info.ts +3 -2
- package/src/init.ts +147 -14
- package/src/install.ts +268 -0
- package/src/keygen.ts +88 -0
- package/src/layout.ts +31 -0
- package/src/lifecycle/__tests__/authoring.test.ts +109 -0
- package/src/lifecycle/__tests__/bundle.test.ts +150 -0
- package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
- package/src/lifecycle/__tests__/fixtures/README.md +59 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
- package/src/lifecycle/__tests__/helpers.ts +78 -0
- package/src/lifecycle/__tests__/install.test.ts +514 -0
- package/src/lifecycle/__tests__/keygen.test.ts +61 -0
- package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
- package/src/lifecycle/__tests__/publish.test.ts +80 -0
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
- package/src/lifecycle/__tests__/sign.test.ts +131 -0
- package/src/lifecycle/__tests__/smoke.test.ts +61 -0
- package/src/lifecycle/__tests__/trust.test.ts +177 -0
- package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
- package/src/lifecycle/keygen.ts +40 -0
- package/src/lifecycle/sign.ts +74 -0
- package/src/manifest-contract.ts +696 -0
- package/src/oj-cert.ts +19 -0
- package/src/oj-cmd.ts +3 -3
- package/src/preview.ts +2 -2
- package/src/publish.ts +150 -0
- package/src/sign.ts +128 -0
- package/src/site.ts +146 -0
- package/src/trust.ts +116 -0
- package/src/uninstall.ts +29 -0
- package/src/usage.ts +35 -1
- package/templates/README.md +1 -1
- package/templates/api/config.yaml +47 -0
- package/templates/api/seed.mysql.sql +1 -1
- package/templates/api/seed.pg.sql +1 -1
- package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
- package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
- package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
- package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
- package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
- package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
- package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
- package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
- package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
- package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
- package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
- package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
- package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
- package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
- package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
- package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
- package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
- package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
- package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
- package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
- package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
- package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
- package/templates/api/src/_platform/_shared/service/access.ts +2 -2
- package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
- package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
- package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
- package/templates/api/src/_platform/manifest.yaml +1 -0
- package/templates/api/src/_platform/market/api.ts +883 -0
- package/templates/api/src/_platform/market/bootstrap.ts +39 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
- package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
- package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
- package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
- package/templates/api/src/_platform/schema.yaml +21 -4
- package/templates/api/src/_platform/seed.sql +2 -2
- package/templates/api/src/_platform/solutions/api.ts +212 -0
- package/templates/api/src/demo/manifest.yaml +5 -0
- package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
- package/templates/api/src/demo/schema.yaml +11 -0
- package/templates/api/src/demo/seed.sql +5 -0
- package/templates/api/src/demo/todos/api.ts +59 -0
- package/templates/api/src/home/line/api.ts +57 -0
- package/templates/api/src/home/manifest.yaml +6 -0
- package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
- package/templates/api/src/home/pie/api.ts +40 -0
- package/templates/api/src/home/schema.yaml +14 -0
- package/templates/api/src/home/seed.sql +70 -0
- package/templates/api/src/system/oplog/api.ts +44 -2
- package/templates/api/tests/fixture.ts +16 -0
- package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
- package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
- package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
- package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
- package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
- package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
- package/templates/api/tests/lifecycle/register.spec.ts +194 -0
- package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
- package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
- package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
- package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
- package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
- package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
- package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
- package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
- package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
- package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
- package/templates/api/tests/rbac-settings.spec.ts +8 -0
- package/templates/init.include.yaml +56 -0
- package/templates/openapi.json +527 -0
- package/templates/package.json +52 -0
- package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
- package/templates/web/src/_platform/__client/api.ts +335 -0
- package/templates/web/src/about/entry.ts +42 -0
- package/templates/web/src/about/locales/en-US.json +16 -0
- package/templates/web/src/about/locales/zh-CN.json +16 -0
- package/templates/web/src/about/pages/constants.ts +20 -0
- package/templates/web/src/about/pages/index.tsx +135 -0
- package/templates/web/src/access/entry.ts +74 -0
- package/templates/web/src/access/locales/en-US.json +46 -0
- package/templates/web/src/access/locales/zh-CN.json +46 -0
- package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
- package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/button-control/index.tsx +181 -0
- package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/page-control/index.tsx +142 -0
- package/templates/web/src/demo/__client/api.schemas.ts +20 -0
- package/templates/web/src/demo/__client/api.ts +89 -0
- package/templates/web/src/exception/entry.ts +80 -0
- package/templates/web/src/exception/locales/en-US.json +13 -0
- package/templates/web/src/exception/locales/zh-CN.json +13 -0
- package/templates/web/src/exception/pages/403/index.tsx +30 -0
- package/templates/web/src/exception/pages/404/index.tsx +30 -0
- package/templates/web/src/exception/pages/500/index.tsx +30 -0
- package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
- package/templates/web/src/home/__client/api.schemas.ts +22 -0
- package/templates/web/src/home/__client/api.ts +113 -0
- package/templates/web/src/login/locales/en-US.json +6 -0
- package/templates/web/src/login/locales/zh-CN.json +6 -0
- package/templates/web/src/login/pages/login.tsx +67 -18
- package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
- package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
- package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
- package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
- package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
- package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
- package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
- package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
- package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
- package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
- package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
- package/templates/web/src/oj-market/api/client.ts +135 -0
- package/templates/web/src/oj-market/api/context.ts +11 -0
- package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
- package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
- package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
- package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
- package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
- package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
- package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
- package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
- package/templates/web/src/oj-market/entry.ts +27 -0
- package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
- package/templates/web/src/oj-market/locales/en-US.json +100 -0
- package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
- package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
- package/templates/web/src/oj-market/types.ts +84 -0
- package/templates/web/src/oj-market/utils/poll.ts +36 -0
- package/templates/web/src/oj-market/utils/semver.ts +14 -0
- package/templates/web/src/outside/entry.ts +80 -0
- package/templates/web/src/outside/locales/en-US.json +10 -0
- package/templates/web/src/outside/locales/zh-CN.json +10 -0
- package/templates/web/src/personal-center/entry.ts +1 -10
- package/templates/web/src/personal-center/locales/en-US.json +1 -5
- package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
- package/templates/web/src/route-nest/entry.ts +73 -0
- package/templates/web/src/route-nest/locales/en-US.json +9 -0
- package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
- package/templates/web/src/system/__client/api.schemas.ts +12 -0
- package/templates/web/src/system/__client/api.ts +25 -1
- package/templates/web/src/system/pages/oplog/index.tsx +1 -1
- package/templates/web/src/system/pages/roles/index.tsx +1 -0
- package/templates/web/src/system/pages/settings/index.tsx +1 -1
- package/templates/web/src/system/pages/users/index.tsx +1 -1
- package/templates/web.config.ts +32 -40
- package/vendor/host-versions.json +3 -3
- package/templates/pnpm-workspace.yaml +0 -14
- 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
|
+
});
|