@oj-module/cli 0.2.8 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/devkit/README.md +70 -24
  2. package/devkit/framework-dev.md +14 -5
  3. package/devkit/manual.md +9 -6
  4. package/devkit/module-dev-and-cdn-playground.md +197 -0
  5. package/devkit/sql-builder-missing-capabilities-pr.md +38 -4
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +130 -45
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/build-backend.test.ts +64 -0
  11. package/src/__tests__/versions.test.ts +26 -0
  12. package/src/build.ts +9 -0
  13. package/src/bundle.ts +8 -5
  14. package/src/contract/__tests__/market-client.integration.test.ts +13 -10
  15. package/src/index.ts +6 -0
  16. package/src/init.ts +22 -0
  17. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  18. package/src/market/__tests__/bootstrap.test.ts +204 -0
  19. package/src/market/__tests__/build-index.test.ts +154 -0
  20. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  21. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  22. package/src/market/__tests__/fixture.test.ts +48 -0
  23. package/src/market/__tests__/publish.test.ts +109 -0
  24. package/src/market/__tests__/report.test.ts +51 -0
  25. package/src/market/__tests__/setup.test.ts +114 -0
  26. package/src/market/__tests__/verify.test.ts +111 -0
  27. package/src/market/bootstrap.ts +255 -0
  28. package/src/market/build-index.ts +159 -0
  29. package/src/market/cdn-layout.ts +122 -0
  30. package/src/market/command.ts +139 -0
  31. package/src/market/failure-playbook.ts +57 -0
  32. package/src/market/fixture.ts +138 -0
  33. package/src/market/index-builder.ts +76 -0
  34. package/src/market/publish.ts +156 -0
  35. package/src/market/report.ts +67 -0
  36. package/src/market/scan.ts +99 -0
  37. package/src/market/setup.ts +144 -0
  38. package/src/market/trial.ts +121 -0
  39. package/src/market/verify.ts +298 -0
  40. package/src/market/walkthrough.ts +506 -0
  41. package/src/oj.ts +3 -1
  42. package/src/preview-config.ts +14 -0
  43. package/src/preview.ts +4 -3
  44. package/src/sign.ts +21 -6
  45. package/src/usage.ts +19 -0
  46. package/src/vendor.ts +4 -2
  47. package/src/versions.ts +28 -0
  48. package/templates/api/.ojm-api-exempt.json +6 -2
  49. package/templates/api/config.yaml +20 -0
  50. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  51. package/templates/api/src/_platform/_lifecycle/uninstall.ts +21 -53
  52. package/templates/api/src/_platform/_shared/service/domain/contacts.ts +15 -10
  53. package/templates/api/src/_platform/_shared/service/domain/solution-perms.ts +8 -6
  54. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +16 -23
  55. package/templates/api/src/_platform/_shared/service/infra/query.ts +18 -0
  56. package/templates/api/src/_platform/_shared/service/infra/rate.ts +4 -1
  57. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  58. package/templates/api/src/_platform/manifest.yaml +1 -0
  59. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  60. package/templates/api/src/_platform/schema.yaml +12 -0
  61. package/templates/api/src/auth/_shared/session.ts +2 -2
  62. package/templates/api/src/demo/todos/api.ts +8 -9
  63. package/templates/api/src/notification/_shared/notify.ts +64 -26
  64. package/templates/api/src/notification/api.ts +1 -1
  65. package/templates/api/src/notification/manifest.yaml +1 -0
  66. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  67. package/templates/api/src/notification/schema.yaml +7 -0
  68. package/templates/api/src/notification/seed.sql +5 -4
  69. package/templates/api/src/personal-center/profile/api.ts +63 -0
  70. package/templates/api/src/web/user-info/api.ts +3 -1
  71. package/templates/api/tests/fixture.ts +8 -16
  72. package/templates/api/tests/l1-builder.test.ts +91 -0
  73. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  74. package/templates/api/tests/lifecycle/market-api.spec.ts +1 -3
  75. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  76. package/templates/api/tests/lifecycle/uninstall.spec.ts +28 -88
  77. package/templates/api/tests/query-utils.spec.ts +85 -1
  78. package/templates/api/tests/rbac-settings.spec.ts +64 -19
  79. package/templates/cdn/README.md +48 -0
  80. package/templates/global.d.ts +36 -2
  81. package/templates/init.include.yaml +3 -2
  82. package/templates/openapi.json +55 -10
  83. package/templates/web/src/notification/entry.ts +46 -9
  84. package/templates/web/src/notification/locales/en-US.json +19 -0
  85. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  86. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  87. package/templates/web/src/oj-market/entry.ts +8 -2
  88. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  89. package/templates/web/src/personal-center/__client/api.ts +25 -1
  90. package/templates/web/src/personal-center/entry.ts +5 -0
  91. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  92. package/templates/web/src/system/entry.ts +2 -1
  93. package/templates/web.config.ts +10 -7
  94. package/vendor/host-versions.json +3 -3
  95. package/templates/api/src/notifications/api.ts +0 -29
  96. package/templates/api/src/notifications/manifest.yaml +0 -5
  97. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  98. package/templates/web/src/notifications/__client/api.ts +0 -80
@@ -0,0 +1,96 @@
1
+ // market CDN 布局 spec(T2):cdn 目录与 `/cdn` 基址的单一真相。
2
+ //
3
+ // 逐条钉死「不许硬编码」——config 里的主机 / 端口 / 挂载路径任一改动,解析结果必须跟随。
4
+ // 之所以值得单独一组用例:dev 形态下 `/cdn` 只在 oj 子进程(9778)上成立,
5
+ // 一旦有人图省事写相对路径或硬编码端口,市场页拿到的会是 index.html 而不是 YAML
6
+ // (设计 §3.1.1)。合规与否由这组用例兜住,而不是靠注释提醒。
7
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join, resolve } from "node:path";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import { CDN_MOUNT_PREFIX, resolveCdnLayout, resolveCdnLayoutFromConfig } from "../cdn-layout";
13
+
14
+ // 绝对路径:真实调用里 configPath 是绝对的,这里不能拿相对路径假装(否则 path.resolve 会相对 cwd 解析)
15
+ const PROJECT = resolve("proj", "my-app");
16
+ const CONFIG_PATH = join(PROJECT, "api", "config.yaml");
17
+
18
+ /** 拼一份 config:默认端口与主机取自模板实际值 */
19
+ function configOf(mounts: string, server = " host: 127.0.0.1\n port: 9778"): string {
20
+ return `mounts:\n${mounts}server:\n${server}\n`;
21
+ }
22
+
23
+ const STD_MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
24
+
25
+ describe("resolveCdnLayoutFromConfig:一切取值都跟随 config", () => {
26
+ it("given 模板默认挂载 When 解析 Then 目录在工程根 cdn/、基址无尾斜杠", () => {
27
+ const layout = resolveCdnLayoutFromConfig(configOf(STD_MOUNTS), CONFIG_PATH);
28
+
29
+ expect(layout.dir).toBe(join(PROJECT, "cdn"));
30
+ expect(layout.prefix).toBe(CDN_MOUNT_PREFIX);
31
+ expect(layout.baseUrl).toBe("http://127.0.0.1:9778/cdn");
32
+ });
33
+
34
+ it("given server.port 改成 9799 When 解析 Then 基址端口跟随(不硬编码 9778)", () => {
35
+ const layout = resolveCdnLayoutFromConfig(configOf(STD_MOUNTS, " host: 127.0.0.1\n port: 9799"), CONFIG_PATH);
36
+
37
+ expect(layout.baseUrl).toBe("http://127.0.0.1:9799/cdn");
38
+ });
39
+
40
+ it("given server.host 改成 127.0.0.2 When 解析 Then 基址主机跟随", () => {
41
+ const layout = resolveCdnLayoutFromConfig(configOf(STD_MOUNTS, " host: 127.0.0.2\n port: 9778"), CONFIG_PATH);
42
+
43
+ expect(layout.baseUrl).toBe("http://127.0.0.2:9778/cdn");
44
+ });
45
+
46
+ it("given 挂载的 web 改成 ../static-cdn When 解析 Then 目录跟随(单一真相在 config)", () => {
47
+ const mounts = " - prefix: /cdn\n web: ../static-cdn\n";
48
+
49
+ const layout = resolveCdnLayoutFromConfig(configOf(mounts), CONFIG_PATH);
50
+
51
+ expect(layout.dir).toBe(join(PROJECT, "static-cdn"));
52
+ });
53
+
54
+ it("given 挂载前缀带尾斜杠 When 解析 Then 前缀归一化掉尾斜杠(拼接口径统一)", () => {
55
+ const mounts = " - prefix: /cdn/\n web: ../cdn\n";
56
+
57
+ const layout = resolveCdnLayoutFromConfig(configOf(mounts), CONFIG_PATH);
58
+
59
+ expect(layout.prefix).toBe(CDN_MOUNT_PREFIX);
60
+ expect(layout.baseUrl).toBe("http://127.0.0.1:9778/cdn");
61
+ });
62
+ });
63
+
64
+ describe("resolveCdnLayoutFromConfig:出错要给得出手", () => {
65
+ it("given mounts 里没有 /cdn 条目 When 解析 Then 抛错且给出要粘回 config 的两行", () => {
66
+ const mounts = " - prefix: /api\n api: src\n";
67
+
68
+ expect(() => resolveCdnLayoutFromConfig(configOf(mounts), CONFIG_PATH)).toThrow(/cdn/);
69
+
70
+ try {
71
+ resolveCdnLayoutFromConfig(configOf(mounts), CONFIG_PATH);
72
+ }
73
+ catch (error) {
74
+ // 人话错误里必须给出可直接粘贴的挂载声明,而不是只抱怨
75
+ expect(String(error)).toContain("prefix: /cdn");
76
+ expect(String(error)).toContain("web: ../cdn");
77
+ }
78
+ });
79
+
80
+ it("given server.port 缺失 When 解析 Then 抛人话错误(与 oj-config.readOjPort 同口径)", () => {
81
+ expect(() => resolveCdnLayoutFromConfig(configOf(STD_MOUNTS, " host: 127.0.0.1"), CONFIG_PATH)).toThrow(/server\.port/);
82
+ });
83
+ });
84
+
85
+ describe("resolveCdnLayout:从工程根出发", () => {
86
+ it("given 一个带 api/config.yaml 的工程 When 解析 Then 与文本版结果一致", () => {
87
+ const root = mkdtempSync(join(tmpdir(), "ojm-cdn-layout-"));
88
+ mkdirSync(join(root, "api"), { recursive: true });
89
+ writeFileSync(join(root, "api", "config.yaml"), configOf(STD_MOUNTS), "utf-8");
90
+
91
+ const layout = resolveCdnLayout(root);
92
+
93
+ expect(layout.dir).toBe(join(root, "cdn"));
94
+ expect(layout.baseUrl).toBe("http://127.0.0.1:9778/cdn");
95
+ });
96
+ });
@@ -0,0 +1,33 @@
1
+ // market/failure-playbook spec(T12 收尾):失败文案表的 traceability——
2
+ // 每个 neg 场景预期命中的失败码都必须有双段式条目(成因 + 下一步),漏一条即失败。
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { FAILURE_PLAYBOOK } from "../failure-playbook";
6
+
7
+ /** 各 neg op 预期命中的失败码(补新 neg op 时同步加行,保持可追溯) */
8
+ const REQUIRED_CODES = [
9
+ "sig_missing", // neg-nosig
10
+ "tampered", // neg-tamper
11
+ "unknown_key", // neg-stranger
12
+ "rollback_guard", // rollback
13
+ "index_sig", // neg-badindex
14
+ "zip_slip", // neg-evil
15
+ ] as const;
16
+
17
+ describe("given 失败文案表 When 逐条核对", () => {
18
+ it("then 每个 neg 场景的预期失败码都有条目,且双段式字段非空", () => {
19
+ const byCode = new Map(FAILURE_PLAYBOOK.map(e => [e.code, e]));
20
+ for (const code of REQUIRED_CODES) {
21
+ const entry = byCode.get(code);
22
+ expect(entry, `缺失败码 ${code} 的文案条目`).toBeDefined();
23
+ expect(entry!.stage.trim()).not.toBe("");
24
+ expect(entry!.cause.trim()).not.toBe("");
25
+ expect(entry!.next.trim()).not.toBe("");
26
+ }
27
+ });
28
+
29
+ it("then code 无重复(表是字典不是日志)", () => {
30
+ const codes = FAILURE_PLAYBOOK.map(e => e.code);
31
+ expect(new Set(codes).size).toBe(codes.length);
32
+ });
33
+ });
@@ -0,0 +1,48 @@
1
+ // market/fixture spec(T12):合成方案树的版本差异——迁移阶梯是 upgrade/rollback op 的物理前提。
2
+ //
3
+ // 0.2.0 起(minor ≥ 2)多一段 0002__more.sql:upgrade 必须真的多走一段迁移,
4
+ // rollback 才有「包内最大 seq < 账本 head」可拦。确定性:同 name@version 输出恒同。
5
+ import { mkdtempSync, readdirSync, readFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { gunzipSync } from "node:zlib";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { buildEvilZipSlipTgz, buildFixturePackage } from "../fixture";
12
+
13
+ describe("given 合成 fixture 方案树 When 看 migrations 目录", () => {
14
+ it("then 0.1.0 只有 0001(head 上限 1)", () => {
15
+ const pkg = buildFixturePackage({ stagingDir: mkdtempSync(join(tmpdir(), "ojm-fixture-")), name: "demo-solution", version: "0.1.0" });
16
+ expect(readdirSync(join(pkg.dir, "api", "demo-solution-0.1.0", "migrations")).sort()).toEqual(["0001__init.sql"]);
17
+ });
18
+
19
+ it("then 0.2.0 多一段 0002(升级真的多走一段迁移)", () => {
20
+ const pkg = buildFixturePackage({ stagingDir: mkdtempSync(join(tmpdir(), "ojm-fixture-")), name: "demo-solution", version: "0.2.0" });
21
+ const files = readdirSync(join(pkg.dir, "api", "demo-solution-0.2.0", "migrations")).sort();
22
+ expect(files).toEqual(["0001__init.sql", "0002__more.sql"]);
23
+ // 0001 与 0.1.0 逐字节一致(upgrade op 断言依据)
24
+ const pkg010 = buildFixturePackage({ stagingDir: mkdtempSync(join(tmpdir(), "ojm-fixture-")), name: "demo-solution", version: "0.1.0" });
25
+ expect(readFileSync(join(pkg.dir, "api", "demo-solution-0.2.0", "migrations", "0001__init.sql")))
26
+ .toEqual(readFileSync(join(pkg010.dir, "api", "demo-solution-0.1.0", "migrations", "0001__init.sql")));
27
+ });
28
+
29
+ it("then 0002 是合法 ALTER(作用于 fixture 声明的表)", () => {
30
+ const pkg = buildFixturePackage({ stagingDir: mkdtempSync(join(tmpdir(), "ojm-fixture-")), name: "demo-solution", version: "0.2.0" });
31
+ const sql = readFileSync(join(pkg.dir, "api", "demo-solution-0.2.0", "migrations", "0002__more.sql"), "utf-8");
32
+ expect(sql).toContain("ALTER TABLE demo_solution_note");
33
+ });
34
+ });
35
+
36
+ describe("given 手造 zip-slip 恶意 tgz When 检查字节", () => {
37
+ it("then 是合法 gzip/tar 且含 ../ 越界成员,同输入恒同字节(确定性)", () => {
38
+ const a = join(mkdtempSync(join(tmpdir(), "ojm-evil-")), "evil.tgz");
39
+ const b = join(mkdtempSync(join(tmpdir(), "ojm-evil-")), "evil.tgz");
40
+ buildEvilZipSlipTgz(a);
41
+ buildEvilZipSlipTgz(b);
42
+ const raw = readFileSync(a);
43
+ expect([raw[0], raw[1]]).toEqual([0x1F, 0x8B]); // gzip magic
44
+ expect(readFileSync(b)).toEqual(raw); // 确定性(mtime 置 0)
45
+ const tar = gunzipSync(raw).toString("utf-8");
46
+ expect(tar).toContain("../evil.sql"); // R1 必须能检出这个成员
47
+ });
48
+ });
@@ -0,0 +1,109 @@
1
+ // ojm market publish spec(T3):落包到 cdn/ —— 只产出 `.tgz` 与包级 `.tgz.sig`。
2
+ //
3
+ // 钉死 D-1 的职责边界:publish **不碰索引**(不得生成 bundles.yaml / manifests.yaml),
4
+ // 也不得把私钥写进任何目录(私钥是否入盘由隔离场景纪律兜,这里断言 cdn 与暂存区无 PEM)。
5
+ import { Buffer } from "node:buffer";
6
+ import { generateKeyPairSync } from "node:crypto";
7
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import { verifyCanonical } from "../../lifecycle/sign";
13
+ import { publishPackage } from "../publish";
14
+
15
+ const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
16
+ const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
17
+ const KEY_ID = "demo.publisher";
18
+
19
+ /** Ed25519 测试身份:私钥只在内存,路径从不落盘 */
20
+ function testIdentity(): { privateKeyPem: string, publicKeyHex: string } {
21
+ const kp = generateKeyPairSync("ed25519");
22
+ return {
23
+ privateKeyPem: kp.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
24
+ publicKeyHex: Buffer.from(kp.publicKey.export({ format: "der", type: "spki" }).subarray(-32)).toString("hex"),
25
+ };
26
+ }
27
+
28
+ function newProject(): string {
29
+ const root = mkdtempSync(join(tmpdir(), "ojm-market-publish-"));
30
+ mkdirSync(join(root, "api"), { recursive: true });
31
+ writeFileSync(join(root, "api", "config.yaml"), CONFIG, "utf-8");
32
+ mkdirSync(join(root, "cdn"), { recursive: true });
33
+ return root;
34
+ }
35
+
36
+ describe("given cdn/ 为空 When 发布 fixture", () => {
37
+ it("then 产出包与其包级签名,且不碰索引", async () => {
38
+ const root = newProject();
39
+ const identity = testIdentity();
40
+
41
+ const result = await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem });
42
+
43
+ expect(existsSync(result.tgzPath)).toBe(true);
44
+ expect(existsSync(result.sigPath)).toBe(true);
45
+ // D-1:索引不是 publish 的事
46
+ expect(existsSync(join(root, "cdn", "bundles.yaml"))).toBe(false);
47
+ expect(existsSync(join(root, "cdn", "manifests.yaml"))).toBe(false);
48
+ });
49
+
50
+ it("then .sig 是可复核的合法 detached 签名(横向复核)", async () => {
51
+ const root = newProject();
52
+ const identity = testIdentity();
53
+
54
+ const result = await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem });
55
+ const sig = JSON.parse(readFileSync(result.sigPath, "utf-8")) as { key_id: string, public_key: string, value: string, canon: string };
56
+
57
+ expect(sig.key_id).toBe(KEY_ID);
58
+ expect(sig.public_key).toBe(identity.publicKeyHex);
59
+ expect(sig.canon).toBe("v1");
60
+ expect(verifyCanonical(sig.public_key, result.canonical, sig.value)).toBe(true);
61
+ });
62
+
63
+ it("then 私钥不落盘:cdn 与工程根都查不到 PEM", async () => {
64
+ const root = newProject();
65
+ const identity = testIdentity();
66
+
67
+ await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem });
68
+
69
+ const pemInCdn = readdirSync(join(root, "cdn")).filter(f => f.endsWith(".pem"));
70
+ expect(pemInCdn).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe("given 同 name@version 再次发布", () => {
75
+ it("when 内容一致 Then 幂等覆盖(sha256 不变)", async () => {
76
+ const root = newProject();
77
+ const identity = testIdentity();
78
+ const base = { projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem };
79
+
80
+ const first = await publishPackage(base);
81
+ const second = await publishPackage(base);
82
+
83
+ expect(second.sha256).toBe(first.sha256);
84
+ expect(second.tgzPath).toBe(first.tgzPath);
85
+ });
86
+
87
+ it("when 已有同版本文件但字节不同 Then 报冲突并提示升版本", async () => {
88
+ const root = newProject();
89
+ const identity = testIdentity();
90
+ const target = join(root, "cdn", "demo-solution-0.1.0.tgz");
91
+ writeFileSync(target, "not-a-real-tgz-but-different-bytes", "utf-8");
92
+
93
+ await expect(publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem }))
94
+ .rejects
95
+ .toThrow(/version/);
96
+ });
97
+ });
98
+
99
+ describe("given 签名身份缺失 When 发布", () => {
100
+ it("then 抛人话错误,不产出半截包", async () => {
101
+ const root = newProject();
102
+
103
+ await expect(publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0" }))
104
+ .rejects
105
+ .toThrow(/-id/);
106
+
107
+ expect(existsSync(join(root, "cdn", "demo-solution-0.1.0.tgz"))).toBe(false);
108
+ });
109
+ });
@@ -0,0 +1,51 @@
1
+ // market/report spec(T12 收尾):报告白黑名单——白名单内容落盘,黑名单字段写盘前被掩码。
2
+ import { mkdtempSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { FAILURE_PLAYBOOK } from "../failure-playbook";
8
+ import { sanitizeReportText, writeWalkthroughReport } from "../report";
9
+
10
+ describe("given 含黑名单字段的文本 When 过脱敏", () => {
11
+ it("then Bearer token / JWT / PEM / jwt_secret 全部被掩码,白名单内容(指纹/sha256/key_id)原样保留", () => {
12
+ const text = [
13
+ "authorization: Bearer abc.def-123~",
14
+ "jwt: eyJhbGciOiJ.eyJzdWI.SflKxw",
15
+ "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMG\n-----END PRIVATE KEY-----",
16
+ "jwt_secret: hunter2",
17
+ "fingerprint 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 / key_id demo.publisher / sha256 abc123",
18
+ ].join("\n");
19
+ const out = sanitizeReportText(text);
20
+ expect(out).not.toContain("abc.def-123");
21
+ expect(out).not.toContain("eyJhbGciOiJ");
22
+ expect(out).not.toContain("MIGHAgEAMBMG");
23
+ expect(out).not.toContain("hunter2");
24
+ expect(out).toContain("Bearer ***");
25
+ expect(out).toContain("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"); // 白名单原样
26
+ expect(out).toContain("demo.publisher");
27
+ // 幂等:二次过检不再变化
28
+ expect(sanitizeReportText(out)).toBe(out);
29
+ });
30
+ });
31
+
32
+ describe("given 一次 walkthrough 的行 When 落盘报告", () => {
33
+ it("then 写出 AC 表 + 文案表,且 detail 里的 token 被掩码", () => {
34
+ const dir = mkdtempSync(join(tmpdir(), "ojm-report-"));
35
+ const file = writeWalkthroughReport({
36
+ rows: [
37
+ { key: "install", title: "安装(11 段)", ok: true, detail: "" },
38
+ { key: "neg-x", title: "负向示例", ok: false, detail: "泄漏 Authorization: Bearer leAk.ed-tok~en" },
39
+ ],
40
+ port: 49999,
41
+ playbook: FAILURE_PLAYBOOK,
42
+ logDir: dir,
43
+ });
44
+ const text = readFileSync(file, "utf-8");
45
+ expect(file).toContain(join(dir, "walkthrough-"));
46
+ expect(text).toContain("| install | PASS | 安装(11 段) |");
47
+ expect(text).toContain("| neg-x | FAIL |");
48
+ expect(text).toContain("sig_missing"); // 文案表段在场
49
+ expect(text).not.toContain("leAk.ed-tok"); // 黑名单兜底生效
50
+ });
51
+ });
@@ -0,0 +1,114 @@
1
+ // ojm market setup spec(T5):首跑打开市场地址。
2
+ //
3
+ // 两条必守:① 写出的地址必须是 config 推导的**绝对 URL**(dev 形态下相对路径会拿到 index.html,
4
+ // 见设计 §3.1.1);② seed admin 处于 must_change 悬挂期时,登录拿的是限权 token,
5
+ // 必须先改密拿全权 token 才写得了 system 设置——这条不闭环,首跑就直接卡死。
6
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { setupMarketUrl } from "../setup";
12
+
13
+ const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
14
+ const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
15
+ const MARKET_KEY = "framework.market_cdn_url";
16
+
17
+ interface Call {
18
+ url: string
19
+ method: string
20
+ body?: Record<string, unknown>
21
+ auth?: string
22
+ }
23
+
24
+ /** 假站点:可编排 login 是否受限、settings 是否拒绝 */
25
+ function fakeSite(opts: { restricted?: boolean, rejectSettings?: boolean, rejectLogin?: boolean } = {}) {
26
+ const calls: Call[] = [];
27
+ const fetchFn = (async (url: string, init?: { method?: string, body?: string, headers?: Record<string, string> }): Promise<Response> => {
28
+ const method = init?.method ?? "GET";
29
+ const body = init?.body === undefined ? undefined : JSON.parse(init.body) as Record<string, unknown>;
30
+ calls.push({ url, method, body, auth: init?.headers?.authorization });
31
+ if (url.endsWith("/api/auth/login")) {
32
+ if (opts.rejectLogin === true)
33
+ return json({ code: 401, msg: "用户名或密码错误", data: null });
34
+ return json({ code: 0, msg: "ok", data: { access_token: "token-limited", restricted: opts.restricted === true } });
35
+ }
36
+ if (url.endsWith("/api/auth/password"))
37
+ return json({ code: 0, msg: "ok", data: { access_token: "token-full", refresh_token: "r", expires_in: 3600 } });
38
+ if (url.endsWith("/api/system/settings")) {
39
+ if (opts.rejectSettings === true)
40
+ return json({ code: 403, msg: "must_change", data: null });
41
+ return json({ code: 0, msg: "ok", data: true });
42
+ }
43
+ throw new Error(`未预期的请求:${method} ${url}`);
44
+ }) as unknown as typeof fetch;
45
+ return { calls, fetchFn };
46
+ }
47
+
48
+ function json(payload: unknown): Response {
49
+ return { ok: true, status: 200, text: async () => JSON.stringify(payload) } as unknown as Response;
50
+ }
51
+
52
+ function newProject(): string {
53
+ const root = mkdtempSync(join(tmpdir(), "ojm-market-setup-"));
54
+ mkdirSync(join(root, "api"), { recursive: true });
55
+ writeFileSync(join(root, "api", "config.yaml"), CONFIG, "utf-8");
56
+ mkdirSync(join(root, "cdn"), { recursive: true });
57
+ return root;
58
+ }
59
+
60
+ describe("given 站点可直连且凭据未悬挂 When setup", () => {
61
+ it("then 直接写入 config 推导的绝对市场地址", async () => {
62
+ const root = newProject();
63
+ const site = fakeSite();
64
+
65
+ const result = await setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", url: "http://127.0.0.1:9778", fetchFn: site.fetchFn });
66
+
67
+ expect(result.cdnBaseUrl).toBe("http://127.0.0.1:9778/cdn");
68
+ const put = site.calls.find(c => c.url.endsWith("/api/system/settings"))!;
69
+ expect(put.method).toBe("PUT");
70
+ expect(put.body).toEqual({ key: MARKET_KEY, value: "http://127.0.0.1:9778/cdn" });
71
+ expect(result.changedPassword).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("given seed admin 处于 must_change 悬挂期 When setup", () => {
76
+ it("then 先改密换全权 token,再写设置(且后续请求用新 token)", async () => {
77
+ const root = newProject();
78
+ const site = fakeSite({ restricted: true });
79
+
80
+ const result = await setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", newPass: "newpass-123", url: "http://127.0.0.1:9778", fetchFn: site.fetchFn });
81
+
82
+ expect(result.changedPassword).toBe(true);
83
+ const pwd = site.calls.find(c => c.url.endsWith("/api/auth/password"))!;
84
+ expect(pwd.method).toBe("PUT");
85
+ expect(pwd.body).toEqual({ old_password: "123456", new_password: "newpass-123" });
86
+ const put = site.calls.find(c => c.url.endsWith("/api/system/settings"))!;
87
+ expect(put.auth).toBe("Bearer token-full");
88
+ });
89
+ });
90
+
91
+ describe("given 站点不可达 When setup", () => {
92
+ it("then 抛人话错误提示先起站点,不写任何东西", async () => {
93
+ const root = newProject();
94
+ const failing = async (): Promise<Response> => {
95
+ throw new Error("ECONNREFUSED");
96
+ };
97
+ const fetchFn = failing as unknown as typeof fetch;
98
+
99
+ await expect(setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", url: "http://127.0.0.1:9778", fetchFn }))
100
+ .rejects
101
+ .toThrow(/ojm dev/);
102
+ });
103
+ });
104
+
105
+ describe("given 改密后写设置仍被拒 When setup", () => {
106
+ it("then 抛人话错误而不是静默成功", async () => {
107
+ const root = newProject();
108
+ const site = fakeSite({ restricted: true, rejectSettings: true });
109
+
110
+ await expect(setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", newPass: "newpass-123", url: "http://127.0.0.1:9778", fetchFn: site.fetchFn }))
111
+ .rejects
112
+ .toThrow(/must_change|权限|设置/);
113
+ });
114
+ });
@@ -0,0 +1,111 @@
1
+ // ojm market verify spec(T7):离线验包——不起站点、不装、不要凭据。
2
+ //
3
+ // 钉死两条:① 四类校验分列(完整性 / 包级签 / 索引签 / 结构),篡改任一项都必须在对应项上打叉;
4
+ // ② 结论恒带边界声明——验签过 ≠ 代码无恶意,这一点写错比校验写错更危险。
5
+ import { Buffer } from "node:buffer";
6
+ import { generateKeyPairSync } from "node:crypto";
7
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import { buildIndex } from "../build-index";
13
+ import { publishPackage } from "../publish";
14
+ import { verifyMarket } from "../verify";
15
+
16
+ const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
17
+ const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
18
+ const KEY_ID = "demo.publisher";
19
+
20
+ function testPem(): string {
21
+ return generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
22
+ }
23
+
24
+ function newProject(): string {
25
+ const root = mkdtempSync(join(tmpdir(), "ojm-market-verify-"));
26
+ mkdirSync(join(root, "api"), { recursive: true });
27
+ writeFileSync(join(root, "api", "config.yaml"), CONFIG, "utf-8");
28
+ mkdirSync(join(root, "cdn"), { recursive: true });
29
+ return root;
30
+ }
31
+
32
+ /** 落一个包并重建索引,返回工程根 */
33
+ async function seed(): Promise<string> {
34
+ const root = newProject();
35
+ await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem() });
36
+ await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
37
+ return root;
38
+ }
39
+
40
+ function itemOf(result: Awaited<ReturnType<typeof verifyMarket>>, name: string): { ok: boolean, detail: string } | undefined {
41
+ return result.checks.find(c => c.item === name);
42
+ }
43
+
44
+ describe("given 一个正常落地的包 When 离线验包", () => {
45
+ it("then 四类校验全绿且结论带边界声明", async () => {
46
+ const root = await seed();
47
+
48
+ const result = await verifyMarket({ projectRoot: root });
49
+
50
+ expect(result.passed).toBe(true);
51
+ expect(itemOf(result, "完整性")?.ok).toBe(true);
52
+ expect(itemOf(result, "包级签名")?.ok).toBe(true);
53
+ expect(itemOf(result, "索引签名")?.ok).toBe(true);
54
+ expect(itemOf(result, "manifest 结构")?.ok).toBe(true);
55
+ expect(result.conclusion).toContain("不构成内容安全性背书");
56
+ });
57
+ });
58
+
59
+ describe("given 包体被改了一个字节 When 离线验包", () => {
60
+ it("then 完整性与包级签名双双打叉(sha256 不符 + 验签不过)", async () => {
61
+ const root = await seed();
62
+ const tgz = join(root, "cdn", "demo-solution-0.1.0.tgz");
63
+ const bytes = Buffer.from(readFileSync(tgz));
64
+ bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 0xFF;
65
+ writeFileSync(tgz, bytes);
66
+
67
+ const result = await verifyMarket({ projectRoot: root });
68
+
69
+ expect(result.passed).toBe(false);
70
+ expect(itemOf(result, "完整性")?.ok).toBe(false);
71
+ expect(itemOf(result, "包级签名")?.ok).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("given 包级签名被删掉 When 离线验包", () => {
76
+ it("then 包级签名打叉并给出 sig_missing 口径的指引", async () => {
77
+ const root = await seed();
78
+ rmSync(join(root, "cdn", "demo-solution-0.1.0.tgz.sig"));
79
+
80
+ const result = await verifyMarket({ projectRoot: root });
81
+
82
+ expect(itemOf(result, "包级签名")?.ok).toBe(false);
83
+ expect(itemOf(result, "包级签名")?.detail).toMatch(/sig|签名/);
84
+ });
85
+ });
86
+
87
+ describe("given 索引被手改过 When 离线验包", () => {
88
+ it("then 索引签名打叉(索引签名是完整性层,篡改必须可见)", async () => {
89
+ const root = await seed();
90
+ const bundles = join(root, "cdn", "bundles.yaml");
91
+ writeFileSync(bundles, `${readFileSync(bundles, "utf-8")}\n# tampered\n`);
92
+
93
+ const result = await verifyMarket({ projectRoot: root });
94
+
95
+ expect(itemOf(result, "索引签名")?.ok).toBe(false);
96
+ });
97
+ });
98
+
99
+ describe("given 落了 tgz 但没重建索引 When 带 --rebuild-check 验包", () => {
100
+ it("then 报漂移:索引缺该包条目", async () => {
101
+ const root = newProject();
102
+ await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem() });
103
+ await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
104
+ await publishPackage({ projectRoot: root, fixture: "other-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem() });
105
+
106
+ const result = await verifyMarket({ projectRoot: root, rebuildCheck: true });
107
+
108
+ expect(result.drift?.length ?? 0).toBeGreaterThan(0);
109
+ expect(result.drift?.join(" ")).toMatch(/other-solution/);
110
+ });
111
+ });