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