@oj-module/cli 0.2.69 → 0.2.71

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 (147) hide show
  1. package/devkit/README.md +10 -8
  2. package/devkit/framework-dev.md +1 -1
  3. package/devkit/manual.md +45 -19
  4. package/devkit/module-dev-and-cdn-playground.md +20 -14
  5. package/devkit/publish-walkthrough.md +132 -43
  6. package/devkit/wecom-customer-reauth-guide.md +110 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/{runtime.zrv2zhj9j0.js → runtime.7x99hmm020.js} +2 -2
  9. package/shell-dist/bundles/oj-market-0.2.0.tgz +0 -0
  10. package/shell-dist/bundles/oj-market-0.2.0.tgz.sig +8 -0
  11. package/shell-dist/index.html +4 -4
  12. package/shell-dist/versions.json +3 -3
  13. package/src/__tests__/list.test.ts +7 -7
  14. package/src/__tests__/oj-health-host.test.ts +19 -0
  15. package/src/__tests__/site-tenant-header.test.ts +2 -2
  16. package/src/__tests__/templates-builtin-list.test.ts +47 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +11 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +2 -3
  19. package/src/__tests__/users-roles-display.test.ts +84 -0
  20. package/src/bundle.ts +1 -1
  21. package/src/contract/__tests__/market-client.integration.test.ts +23 -22
  22. package/src/enable.ts +2 -2
  23. package/src/init.ts +45 -0
  24. package/src/install.ts +5 -5
  25. package/src/lifecycle/__tests__/install.test.ts +29 -29
  26. package/src/lifecycle/__tests__/market-bundle.test.ts +87 -0
  27. package/src/lifecycle/__tests__/publish.test.ts +8 -10
  28. package/src/lifecycle/__tests__/trust.test.ts +14 -14
  29. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  30. package/src/list.ts +3 -3
  31. package/src/market/__tests__/bootstrap.test.ts +24 -7
  32. package/src/market/__tests__/failure-playbook.test.ts +0 -1
  33. package/src/market/__tests__/remote-push.test.ts +228 -0
  34. package/src/market/__tests__/verify.test.ts +45 -207
  35. package/src/market/bootstrap.ts +12 -7
  36. package/src/market/command.ts +33 -35
  37. package/src/market/db-seed.ts +129 -0
  38. package/src/market/failure-playbook.ts +3 -9
  39. package/src/market/fixture.ts +2 -6
  40. package/src/market/publish.ts +145 -89
  41. package/src/market/remote-push.ts +236 -0
  42. package/src/market/trial.ts +2 -2
  43. package/src/market/verify.ts +17 -270
  44. package/src/market/walkthrough.ts +38 -63
  45. package/src/oj.ts +13 -2
  46. package/src/preview.ts +5 -3
  47. package/src/publish.ts +3 -2
  48. package/src/scaffold-guard.ts +4 -0
  49. package/src/site.ts +1 -1
  50. package/src/trust.ts +8 -8
  51. package/src/uninstall.ts +2 -2
  52. package/src/usage.ts +23 -21
  53. package/templates/config.yaml +22 -3
  54. package/templates/init.include.yaml +16 -11
  55. package/templates/openapi.json +3948 -3655
  56. package/templates/src/.ojm-builtin.json +13 -0
  57. package/templates/src/README.md +5 -2
  58. package/templates/src/app/api/_platform/README.md +3 -2
  59. package/templates/src/app/api/_platform/_lifecycle/atomic.ts +7 -2
  60. package/templates/src/app/api/_platform/_lifecycle/bundle-converge.ts +158 -0
  61. package/templates/src/app/api/_platform/_lifecycle/history.ts +3 -2
  62. package/templates/src/app/api/_platform/_lifecycle/installer.ts +25 -5
  63. package/templates/src/app/api/_platform/_lifecycle/local-archive.ts +61 -0
  64. package/templates/src/app/api/_platform/_lifecycle/manifest-of-upload.ts +62 -0
  65. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +12 -2
  66. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +4 -1
  67. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +8 -2
  68. package/templates/src/app/api/_platform/_lifecycle/types.ts +1 -1
  69. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +14 -5
  70. package/templates/src/app/api/_platform/_shared/site-facade.ts +31 -0
  71. package/templates/src/app/api/_platform/manifest.yaml +1 -2
  72. package/templates/src/app/api/_platform/migrations/0003__market_catalog.mysql.sql +6 -0
  73. package/templates/src/app/api/_platform/migrations/0003__market_catalog.pg.sql +6 -0
  74. package/templates/src/app/api/_platform/migrations/0003__market_catalog.sql +18 -0
  75. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.mysql.sql +6 -0
  76. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.pg.sql +6 -0
  77. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.sql +6 -0
  78. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.mysql.sql +8 -0
  79. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.pg.sql +8 -0
  80. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.sql +8 -0
  81. package/templates/src/app/api/_platform/schema.yaml +8 -11
  82. package/templates/src/app/api/_platform/seed.mysql.sql +1 -1
  83. package/templates/src/app/api/_platform/seed.pg.sql +1 -1
  84. package/templates/src/app/api/_platform/seed.sql +1 -1
  85. package/templates/src/app/api/auth/_shared/wecom/sync.ts +6 -0
  86. package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -114
  87. package/templates/src/app/web/_platform/__client/index.ts +1 -333
  88. package/templates/src/app/web/system/locales/en-US.json +15 -1
  89. package/templates/src/app/web/system/locales/zh-CN.json +15 -1
  90. package/templates/src/app/web/system/pages/departments/sync-bar.tsx +9 -3
  91. package/templates/src/app/web/system/pages/roles/index.tsx +4 -8
  92. package/templates/src/app/web/system/pages/roles/role-label.ts +26 -0
  93. package/templates/src/app/web/system/pages/solutions/index.tsx +110 -0
  94. package/templates/src/app/web/system/pages/solutions/market-entry.ts +46 -0
  95. package/templates/src/app/web/system/pages/users/index.tsx +12 -2
  96. package/templates/src/app/web/system/pages/users/match-keyword.ts +19 -0
  97. package/templates/src/app/web/system/pages/users/roles.tsx +43 -14
  98. package/templates/src/app/web/system/routes.ts +18 -1
  99. package/templates/src/{app/api/_platform/market → oj-market/api}/api.ts +475 -592
  100. package/templates/src/oj-market/api/backfill.ts +221 -0
  101. package/templates/src/{app/api/_platform/market → oj-market/api}/bootstrap.ts +2 -2
  102. package/templates/src/oj-market/api/cdn/api.ts +312 -0
  103. package/templates/src/oj-market/api/cdn/shared.ts +104 -0
  104. package/templates/src/oj-market/api/cdn/signed-auth.ts +75 -0
  105. package/templates/src/oj-market/api/manifest.yaml +8 -0
  106. package/templates/src/oj-market/solution.yaml +21 -0
  107. package/templates/src/oj-market/web/__client/api.schemas.ts +167 -0
  108. package/templates/src/oj-market/web/__client/index.ts +509 -0
  109. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +6 -6
  110. package/templates/src/oj-market/web/api/client.ts +6 -6
  111. package/templates/src/oj-market/web/api/defaultClient.ts +31 -25
  112. package/templates/src/oj-market/web/entry.ts +5 -3
  113. package/templates/src/oj-market/web/hooks/useMarket.ts +6 -6
  114. package/templates/src/oj-market/web/pages/MarketApp.tsx +20 -3
  115. package/templates/src/oj-market/web/publish/locales/en-US.json +1 -1
  116. package/templates/src/oj-market/web/publish/locales/zh-CN.json +1 -1
  117. package/templates/src/oj-market/web/publish/pages/history.tsx +2 -11
  118. package/templates/src/oj-market/web/routes.ts +12 -0
  119. package/templates/src/oj-market/web/types.ts +14 -8
  120. package/templates/tests/lifecycle/anonymous-wiring.spec.ts +65 -0
  121. package/templates/tests/lifecycle/backfill.spec.ts +392 -0
  122. package/templates/tests/lifecycle/bootstrap.spec.ts +1 -1
  123. package/templates/tests/lifecycle/bundle-converge.spec.ts +264 -0
  124. package/templates/tests/lifecycle/cdn-upload.spec.ts +661 -0
  125. package/templates/tests/lifecycle/deploy-smoke.spec.ts +25 -0
  126. package/templates/tests/lifecycle/exec-grant.spec.ts +38 -0
  127. package/templates/tests/lifecycle/installer.spec.ts +36 -0
  128. package/templates/tests/lifecycle/market-api.spec.ts +448 -803
  129. package/templates/tests/lifecycle/preflight.spec.ts +18 -0
  130. package/templates/tests/lifecycle/trust.spec.ts +2 -2
  131. package/templates/tests/lifecycle/uninstall.spec.ts +36 -2
  132. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  133. package/templates/tests/solutions-entry.spec.ts +55 -0
  134. package/templates/tsconfig.paths.json +1 -0
  135. package/templates/web.config.ts +6 -6
  136. package/vendor/host-versions.json +3 -3
  137. package/bin/oj +0 -4
  138. package/src/market/__tests__/build-index.test.ts +0 -641
  139. package/src/market/__tests__/sharding.test.ts +0 -90
  140. package/src/market/__tests__/yank-ledger.test.ts +0 -60
  141. package/src/market/build-index.ts +0 -526
  142. package/src/market/index-builder.ts +0 -149
  143. package/src/market/sharding.ts +0 -94
  144. package/src/market/yank-ledger.ts +0 -91
  145. package/templates/src/app/api/_platform/_lifecycle/source-seq.ts +0 -88
  146. package/templates/src/app/api/_platform/market/index-source.ts +0 -112
  147. package/templates/tests/lifecycle/source-seq.spec.ts +0 -55
@@ -5,7 +5,8 @@
5
5
  // 冲突都是不可逆后果。故每条断言都直接查文件系统与配置内容,不靠口头约定。
6
6
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
8
- import { join } from "node:path";
8
+ import { dirname, join } from "node:path";
9
+ import { DatabaseSync } from "node:sqlite";
9
10
  import { describe, expect, it, vi } from "vitest";
10
11
 
11
12
  import { bootstrapIsolatedSite } from "../bootstrap";
@@ -45,11 +46,21 @@ vi.mock("node:child_process", async (importOriginal) => {
45
46
  return { ...actual, execFileSync };
46
47
  });
47
48
 
48
- /** 假 oj 进程:不起真进程,ready 立即就绪(与 dev.ts 的 ojStarter 注入同形态) */
49
+ /**
50
+ * 假 oj 进程:不起真进程,ready 立即就绪(与 dev.ts 的 ojStarter 注入同形态)。
51
+ * 起站语义顺带模拟 migrate_on_start:建 market_release 目录表(真 oj serve 起站即应用迁移),
52
+ * bootstrap 的目录行 seed 依赖该表存在。
53
+ */
49
54
  function fakeStarter() {
50
55
  const started: Array<{ configPath: string, base: string, apiSrc: string }> = [];
51
56
  const starter = (configPath: string, base: string, apiSrc: string) => {
52
57
  started.push({ configPath, base, apiSrc });
58
+ const db = new DatabaseSync(join(dirname(configPath), "db.sqlite"));
59
+ db.prepare(
60
+ "CREATE TABLE IF NOT EXISTS market_release (id INTEGER PRIMARY KEY AUTOINCREMENT, product TEXT NOT NULL, version TEXT NOT NULL, key_id TEXT, sha256 TEXT, size INTEGER, published_at INTEGER NOT NULL, published_at_source TEXT NOT NULL, yanked INTEGER NOT NULL DEFAULT 0, first_seen_at INTEGER NOT NULL, sig_alg TEXT, sig_value TEXT, sig_public_key TEXT, sig_registry_id TEXT, manifest TEXT)",
61
+ ).run();
62
+ db.prepare("CREATE UNIQUE INDEX IF NOT EXISTS uk_market_release_version ON market_release (product, version)").run();
63
+ db.close();
53
64
  return {
54
65
  port: Number(/port: (\d+)/.exec(readFileSync(configPath, "utf-8"))?.[1] ?? "0"),
55
66
  ready: Promise.resolve(),
@@ -159,7 +170,7 @@ describe("given 一个装了 oj 二进制的源工程 When 自举隔离站点",
159
170
  await site.cleanup();
160
171
  });
161
172
 
162
- it("then CDN 目录在临时根内且已落好 fixture 包与索引", async () => {
173
+ it("then CDN 目录在临时根内且已落好 fixture 包;目录行已 seed 进隔离库", async () => {
163
174
  const src = newSourceProject();
164
175
  const { starter } = fakeStarter();
165
176
 
@@ -167,10 +178,16 @@ describe("given 一个装了 oj 二进制的源工程 When 自举隔离站点",
167
178
 
168
179
  expect(site.cdnDir.startsWith(site.root)).toBe(true);
169
180
  expect(existsSync(join(site.cdnDir, "demo-solution-0.1.0.tgz"))).toBe(true);
170
- // REG-CS5 三级索引:catalog.yaml(提交点)+ shards/<n>.yaml(版本明细)及其 .sig
171
- expect(existsSync(join(site.cdnDir, "catalog.yaml"))).toBe(true);
172
- expect(existsSync(join(site.cdnDir, "catalog.yaml.sig"))).toBe(true);
173
- expect(readdirSync(join(site.cdnDir, "shards")).some(f => /^1\.yaml$/.test(f))).toBe(true);
181
+ expect(existsSync(join(site.cdnDir, "demo-solution-0.1.0.tgz.sig"))).toBe(true);
182
+ // A6 DB 目录化:索引文件(catalog.yaml/shards)不再生成——目录真源 = market_release 行
183
+ expect(existsSync(join(site.cdnDir, "catalog.yaml"))).toBe(false);
184
+ // 目录行 seed(站点内部通道):安装解析/index 卡片/releases 的直读数据源
185
+ const { DatabaseSync } = await import("node:sqlite");
186
+ const db = new DatabaseSync(site.dbPath);
187
+ const rows = db.prepare("SELECT product, version, key_id, sig_registry_id FROM market_release").all() as Array<Record<string, unknown>>;
188
+ db.close();
189
+ expect(rows).toHaveLength(1);
190
+ expect(rows[0]).toMatchObject({ product: "demo-solution", version: "0.1.0", key_id: "trial.publisher", sig_registry_id: "trial.registry" });
174
191
  expect(site.cdnBaseUrl).toBe(`${site.baseUrl}/cdn`);
175
192
  await site.cleanup();
176
193
  });
@@ -10,7 +10,6 @@ const REQUIRED_CODES = [
10
10
  "tampered", // neg-tamper
11
11
  "unknown_key", // neg-stranger
12
12
  "rollback_guard", // rollback
13
- "index_sig", // neg-badindex
14
13
  "zip_slip", // neg-evil
15
14
  ] as const;
16
15
 
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `ojm market publish` 远端推送(A6):签名推送 + sig→tgz 顺序 + 错误映射数据表 + 半成功幂等指引。
3
+ *
4
+ * fetch 全 mock:不断真站点、不真推。签名侧用真 Ed25519(generateKeyPair)——
5
+ * 断言推交流程构造的 canonical 可被服务端口径(实收字节重算 sha256)复算验过。
6
+ */
7
+
8
+ import type { RemotePublishResult } from "../remote-push";
9
+ import { createHash } from "node:crypto";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+ import { generateKeyPair } from "../../lifecycle/keygen";
16
+ import { verifyCanonical } from "../../lifecycle/sign";
17
+ import { discoverMarket, remotePublish, remoteYank } from "../remote-push";
18
+
19
+ interface RecordedCall {
20
+ url: string
21
+ method: string
22
+ keyId: string
23
+ signature: string
24
+ bytes: Uint8Array
25
+ }
26
+
27
+ /** fetch mock:按 URL 分发响应,记录调用序(sig→tgz 顺序断言用) */
28
+ function mockFetch(handler: (call: RecordedCall) => { status: number, body: unknown }) {
29
+ const calls: RecordedCall[] = [];
30
+ const fn = async (input: unknown, init?: { method?: string, headers?: Record<string, string>, body?: unknown }) => {
31
+ const url = String(input);
32
+ const headers = init?.headers ?? {};
33
+ const form = init?.body as FormData | undefined;
34
+ const file = typeof form?.get === "function" ? form.get("file") as Blob | null : null;
35
+ const call: RecordedCall = {
36
+ url,
37
+ method: init?.method ?? "GET",
38
+ keyId: headers["x-ojm-key-id"] ?? "",
39
+ signature: headers["x-ojm-signature"] ?? "",
40
+ bytes: file != null
41
+ ? new Uint8Array(await file.arrayBuffer())
42
+ : typeof init?.body === "string" ? new TextEncoder().encode(init.body) : new Uint8Array(),
43
+ };
44
+ calls.push(call);
45
+ const r = handler(call);
46
+ return new Response(JSON.stringify({ code: r.status === 200 ? 0 : r.status, msg: r.status === 200 ? "ok" : String((r.body as { message?: string }).message ?? "err"), data: r.status === 200 ? r.body : undefined }), { status: r.status });
47
+ };
48
+ return { fn: fn as typeof fetch, calls };
49
+ }
50
+
51
+ const SITE = "http://127.0.0.1:9778";
52
+
53
+ let root: string;
54
+ let kp: ReturnType<typeof generateKeyPair>;
55
+
56
+ beforeEach(() => {
57
+ root = mkdtempSync(path.join(tmpdir(), "ojm-remote-push-"));
58
+ kp = generateKeyPair();
59
+ });
60
+
61
+ afterEach(() => {
62
+ rmSync(root, { recursive: true, force: true });
63
+ });
64
+
65
+ describe("discoverMarket(catalog 探测:registry_id 发现 + 旧站点人话)", () => {
66
+ it("200 + registry_id → 解析出站点身份", async () => {
67
+ const { fn } = mockFetch(() => ({ status: 200, body: { api_version: "v1", registry_id: "site.registry" } }));
68
+ const d = await discoverMarket(fn, SITE);
69
+ expect(d.registryId).toBe("site.registry");
70
+ expect(d.apiVersion).toBe("v1");
71
+ });
72
+
73
+ it("registry_id = null → 人话报错指向 vars.registry_id(推送canonical首段必需)", async () => {
74
+ const { fn } = mockFetch(() => ({ status: 200, body: { api_version: "v1", registry_id: null } }));
75
+ await expect(discoverMarket(fn, SITE)).rejects.toThrow(/registry_id/);
76
+ });
77
+
78
+ it("catalog API 404 → 「站点版本过旧」人话 + 迁移指南指向(2×2 矩阵新 CLI×旧站点格)", async () => {
79
+ const { fn } = mockFetch(() => ({ status: 404, body: { message: "not found" } }));
80
+ await expect(discoverMarket(fn, SITE)).rejects.toThrow(/版本过旧/);
81
+ });
82
+
83
+ it("网络层失败 → 人话(不抛原生 fetch 错)", async () => {
84
+ const fn = (() => Promise.reject(new TypeError("fetch failed"))) as typeof fetch;
85
+ await expect(discoverMarket(fn, SITE)).rejects.toThrow(/连不上|无法访问|fetch failed/);
86
+ });
87
+ });
88
+
89
+ describe("remotePublish(签名推送:sig→tgz 顺序 + canonical 口径)", () => {
90
+ it("先推 .sig 旁车再推 tgz(pair 约束:sig 先 tgz 后);canonical 按实收字节重算口径可验签", async () => {
91
+ const { fn, calls } = mockFetch(() => ({ status: 200, body: { api_version: "v1", registry_id: "site.registry" } }));
92
+ const r: RemotePublishResult = await remotePublish({
93
+ projectRoot: root,
94
+ fixture: "demo-solution@0.1.0",
95
+ siteUrl: SITE,
96
+ id: "test.publisher",
97
+ privateKeyPem: kp.privateKeyPem,
98
+ registryId: "pub.registry",
99
+ fetchFn: fn,
100
+ });
101
+ expect(calls).toHaveLength(3); // catalog 探测 + sig + tgz
102
+ expect(calls[0]!.url).toBe(`${SITE}/api/oj-market/catalog?page=1&size=1`);
103
+ expect(calls[1]!.url).toContain("/api/oj-market/cdn/upload?name=");
104
+ expect(calls[1]!.url).toContain(encodeURIComponent(".tgz.sig"));
105
+ expect(calls[2]!.url).toContain(encodeURIComponent(".tgz"));
106
+ expect(calls[2]!.url).not.toContain(".sig");
107
+ // 每请求签名 canonical = cdn-upload:<站点 registry_id>:<文件名>:<实收字节 sha256>(服务端重算口径)
108
+ for (const call of calls.slice(1)) {
109
+ const file = decodeURIComponent(call.url.split("name=")[1]!);
110
+ const sha256 = createHash("sha256").update(call.bytes).digest("hex");
111
+ const canonical = `cdn-upload:site.registry:${file}:${sha256}`;
112
+ expect(call.keyId).toBe("test.publisher");
113
+ expect(verifyCanonical(kp.publicKeyHex, canonical, call.signature)).toBe(true);
114
+ }
115
+ // 包内 manifest 与文件名一致(服务端 400 钳制的前置自检面)
116
+ expect(r.name).toBe("demo-solution");
117
+ expect(r.version).toBe("0.1.0");
118
+ expect(r.registryId).toBe("site.registry");
119
+ expect(r.keyId).toBe("test.publisher");
120
+ });
121
+
122
+ it("缺 --site-url 且环境变量无 → 人话报错(不猜默认站点)", async () => {
123
+ const { fn } = mockFetch(() => ({ status: 200, body: {} }));
124
+ const old = process.env.OJM_SITE_URL;
125
+ delete process.env.OJM_SITE_URL;
126
+ try {
127
+ await expect(remotePublish({ projectRoot: root, id: "test.publisher", privateKeyPem: kp.privateKeyPem, registryId: "pub.registry", fetchFn: fn }))
128
+ .rejects
129
+ .toThrow(/--site-url/);
130
+ }
131
+ finally {
132
+ if (old !== undefined)
133
+ process.env.OJM_SITE_URL = old;
134
+ }
135
+ });
136
+ });
137
+
138
+ describe("remotePublish 错误映射数据表(401/403/409/413 + 半成功幂等指引)", () => {
139
+ async function pushWith(status: number, message: string, failAt: "sig" | "tgz" = "tgz") {
140
+ const { fn } = mockFetch(call => (call.url.includes("/cdn/upload") && (failAt === "sig") === call.url.includes(".sig")
141
+ ? { status, body: { message } }
142
+ : { status: 200, body: { api_version: "v1", registry_id: "site.registry" } }));
143
+ return await remotePublish({
144
+ projectRoot: root,
145
+ fixture: "demo-solution@0.1.0",
146
+ siteUrl: SITE,
147
+ id: "test.publisher",
148
+ privateKeyPem: kp.privateKeyPem,
149
+ registryId: "pub.registry",
150
+ fetchFn: fn,
151
+ });
152
+ }
153
+
154
+ it.each([
155
+ [401, "签名验签失败:服务端按实收字节重算 sha256 重构 canonical 验签——字节被换、registry 身份不符或签名无效,已拒(401)", /签名验签失败/],
156
+ [403, "发布 key「test.publisher」不在本站点信任库——请站点 superadmin 经市场信任确认(或 ojm trust add)入录后重试", /信任库/],
157
+ [409, "「demo-solution@0.1.0」已存在且字节不一致——同一版本号只允许一份字节,请升 version 重发", /升 version/],
158
+ [413, "上传体积超过上限", /体积|413/],
159
+ ] as const)("%i → 人话映射(含下一步指引)", async (status, message, pattern) => {
160
+ await expect(pushWith(status, message)).rejects.toThrow(pattern);
161
+ });
162
+
163
+ it("sig 已推、tgz 被拒(半成功)→ 错误文案含「重跑本命令幂等补齐」", async () => {
164
+ await expect(pushWith(409, "「demo-solution@0.1.0」已存在且字节不一致——同一版本号只允许一份字节", "tgz"))
165
+ .rejects
166
+ .toThrow(/重跑本命令幂等补齐/);
167
+ });
168
+
169
+ describe("remoteYank(签名下线:market-yank canonical + 发布者授权面)", () => {
170
+ it("canonical = market-yank:<站点 registry>:<product>:<version> 可验签;body 携带 reason/by/restore", async () => {
171
+ const { fn, calls } = mockFetch(call => (call.url.includes("/cdn/upload")
172
+ ? { status: 404, body: { message: "n/a" } }
173
+ : call.url.includes("/yank")
174
+ ? { status: 200, body: { ok: true, product: "demo-solution", version: "0.1.0", yanked: true } }
175
+ : { status: 200, body: { api_version: "v1", registry_id: "site.registry" } }));
176
+ const r = await remoteYank({
177
+ siteUrl: SITE,
178
+ name: "demo-solution",
179
+ version: "0.1.0",
180
+ reason: "有严重缺陷",
181
+ by: "release-ops",
182
+ id: "test.publisher",
183
+ privateKeyPem: kp.privateKeyPem,
184
+ fetchFn: fn,
185
+ });
186
+ expect(calls).toHaveLength(2); // catalog 探测 + yank POST
187
+ const yank = calls[1]!;
188
+ expect(yank.url).toBe(`${SITE}/api/oj-market/yank`);
189
+ expect(yank.method).toBe("POST");
190
+ expect(verifyCanonical(kp.publicKeyHex, "market-yank:site.registry:demo-solution:0.1.0", yank.signature)).toBe(true);
191
+ const body = JSON.parse(new TextDecoder().decode(yank.bytes)) as Record<string, unknown>;
192
+ expect(body).toMatchObject({ product: "demo-solution", version: "0.1.0", reason: "有严重缺陷", by: "release-ops" });
193
+ expect(body.restore).toBeUndefined();
194
+ expect(r).toMatchObject({ product: "demo-solution", version: "0.1.0", yanked: true });
195
+ });
196
+
197
+ it("restore 形态 body 带 restore:true;服务端 403(非发布者)→ 人话透出", async () => {
198
+ const { fn } = mockFetch(call => (call.url.includes("/yank")
199
+ ? { status: 403, body: { message: "key「test.publisher」不是「demo-solution@0.1.0」的发布者" } }
200
+ : { status: 200, body: { api_version: "v1", registry_id: "site.registry" } }));
201
+ await expect(remoteYank({
202
+ siteUrl: SITE,
203
+ name: "demo-solution",
204
+ version: "0.1.0",
205
+ restore: true,
206
+ id: "test.publisher",
207
+ privateKeyPem: kp.privateKeyPem,
208
+ fetchFn: fn,
209
+ })).rejects.toThrow(/发布者/);
210
+ });
211
+ });
212
+
213
+ it("幂等重入:服务端返回 idempotent → 视为成功不重抛", async () => {
214
+ const { fn, calls } = mockFetch(call => (call.url.includes("/cdn/upload")
215
+ ? { status: 200, body: { ok: true, idempotent: true } }
216
+ : { status: 200, body: { api_version: "v1", registry_id: "site.registry" } }));
217
+ await remotePublish({
218
+ projectRoot: root,
219
+ fixture: "demo-solution@0.1.0",
220
+ siteUrl: SITE,
221
+ id: "test.publisher",
222
+ privateKeyPem: kp.privateKeyPem,
223
+ registryId: "pub.registry",
224
+ fetchFn: fn,
225
+ });
226
+ expect(calls).toHaveLength(3);
227
+ });
228
+ });
@@ -1,12 +1,11 @@
1
1
  import type { VerifyCheck } from "../verify";
2
- // ojm market verify spec(T7 + REG-CS5 三级化):离线验包——不起站点、不装、不要凭据。
2
+ // ojm market verify spec(A6 瘦身):离线验包——不起站点、不装、不要凭据。
3
3
  //
4
- // 钉死两条:① 四类校验分列(完整性 / 包级签 / 索引签 / 结构),篡改任一项都必须在对应项上打叉;
5
- // ② 结论恒带边界声明——验签过 ≠ 代码无恶意,这一点写错比校验写错更危险。
6
- //
7
- // 三级化后的索引面:catalog.yaml(代次真源)+ shards/<n>.yaml(版本明细与 archive.sha256)。
8
- // 索引验签按文件分列(catalog 一项、每片一项),同代校验单独一项——红队 F2 的中间态
9
- // (新 catalog 钉旧片)必须在同代项上打叉,且按「运维事故」而不是「被篡改」提示。
4
+ // v4 DB 目录化后只剩**包级面**(计划退役清单点名):包级 detached 验签(信任锚)+
5
+ // manifest 结构(必填字段 / 原生清单 desc / 0001 迁移跨版本一致)。索引面(索引验签 /
6
+ // 同代 / 时效 / 漂移 / 对索引声明的完整性比对)随索引机制整建制退役——目录真源已移交
7
+ // 站点 market_release 表,字节-声明一致性由上传端点服务端重算 sha256 在写通道守住。
8
+ // 结论恒带边界声明——验签过 ≠ 代码无恶意,这一点写错比校验写错更危险。
10
9
  import { Buffer } from "node:buffer";
11
10
  import { generateKeyPairSync } from "node:crypto";
12
11
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -16,9 +15,7 @@ import { dirname, join } from "node:path";
16
15
  import { describe, expect, it } from "vitest";
17
16
  import { parse, stringify } from "yaml";
18
17
  import { createDeterministicTgz } from "../../deterministic-tar";
19
- import { recomputeIndex, signIndex } from "../../publish";
20
18
  import { signCommand } from "../../sign";
21
- import { buildIndex } from "../build-index";
22
19
  import { publishPackage } from "../publish";
23
20
  import { readTgz } from "../scan";
24
21
  import { verifyMarket } from "../verify";
@@ -40,11 +37,10 @@ function newProject(): string {
40
37
  return root;
41
38
  }
42
39
 
43
- /** 落一个包并重建索引,返回工程根 */
40
+ /** 落一个已签名包(本地落盘内部通道——索引重建随 DB 目录化退役),返回工程根 */
44
41
  async function seed(): Promise<string> {
45
42
  const root = newProject();
46
43
  await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
47
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
48
44
  return root;
49
45
  }
50
46
 
@@ -52,24 +48,14 @@ function itemOf(result: Awaited<ReturnType<typeof verifyMarket>>, name: string):
52
48
  return result.checks.find(c => c.item === name);
53
49
  }
54
50
 
55
- /** 全部「索引签名」项(三级化后按文件分列:catalog 一项 + 每片一项) */
56
- function sigItems(result: Awaited<ReturnType<typeof verifyMarket>>): VerifyCheck[] {
57
- return result.checks.filter(c => c.item === "索引签名");
58
- }
59
-
60
51
  describe("given 一个正常落地的包 When 离线验包", () => {
61
- it("then 四类校验全绿且结论带边界声明", async () => {
52
+ it("then 包级面校验全绿(包级签名 + manifest 结构)且结论带边界声明", async () => {
62
53
  const root = await seed();
63
54
 
64
55
  const result = await verifyMarket({ projectRoot: root });
65
56
 
66
57
  expect(result.passed).toBe(true);
67
- expect(itemOf(result, "完整性")?.ok).toBe(true);
68
58
  expect(itemOf(result, "包级签名")?.ok).toBe(true);
69
- // 三级索引:catalog 与 1 号片各签各的,两项都在且全绿
70
- const sigs = sigItems(result);
71
- expect(sigs.length).toBeGreaterThanOrEqual(2);
72
- expect(sigs.every(c => c.ok)).toBe(true);
73
59
  expect(itemOf(result, "manifest 结构")?.ok).toBe(true);
74
60
  expect(result.conclusion).toContain("不构成内容安全性背书");
75
61
  });
@@ -79,7 +65,7 @@ describe("given 真实工程包(manifest 按 C2-29 整体省略 archive 占位
79
65
  it("then manifest 结构不因此打叉(REG-CS8 回归:ojm build --bundle 不落 archive ≠ 缺必填)", async () => {
80
66
  const root = await seed();
81
67
  const tgz = join(root, "cdn", "demo-solution-0.1.0.tgz");
82
- // 解包 → 摘掉根 manifest 的 archive(fixture 有、真实方案无的字段差)→ 确定性重打包 → 重签 → 重建索引
68
+ // 解包 → 摘掉根 manifest 的 archive(fixture 有、真实方案无的字段差)→ 确定性重打包 → 重签
83
69
  const staging = mkdtempSync(join(tmpdir(), "ojm-verify-noarchive-"));
84
70
  for (const e of readTgz(new Uint8Array(readFileSync(tgz)))) {
85
71
  if (e.name.endsWith("/"))
@@ -94,7 +80,6 @@ describe("given 真实工程包(manifest 按 C2-29 整体省略 archive 占位
94
80
  writeFileSync(rootManifestPath, stringify(manifest));
95
81
  await createDeterministicTgz(staging, tgz);
96
82
  signCommand(tgz, { id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
97
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
98
83
 
99
84
  const result = await verifyMarket({ projectRoot: root });
100
85
 
@@ -104,21 +89,20 @@ describe("given 真实工程包(manifest 按 C2-29 整体省略 archive 占位
104
89
  });
105
90
 
106
91
  describe("given 预发布版本(文件名含连字符)包体被篡改 When 离线验包", () => {
107
- it("then 完整性仍能检出(不再因文件名解析错配而静默放过,REG-CS1 边界)", async () => {
92
+ it("then 包级签名仍检出篡改(重算 sha256 重构规范串,验签不过——内容寻址不以文件名解析为前提)", async () => {
108
93
  const root = newProject();
109
94
  await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0-rc.1", id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
110
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
111
95
  const tgz = join(root, "cdn", "demo-solution-0.1.0-rc.1.tgz");
112
96
  const buf = readFileSync(tgz);
113
97
  buf[buf.length - 1] ^= 0xFF; // 篡改末字节
114
98
  writeFileSync(tgz, buf);
115
99
  const result = await verifyMarket({ projectRoot: root });
116
- expect(itemOf(result, "完整性")?.ok).toBe(false);
100
+ expect(itemOf(result, "包级签名")?.ok).toBe(false);
117
101
  });
118
102
  });
119
103
 
120
104
  describe("given 包体被改了一个字节 When 离线验包", () => {
121
- it("then 完整性与包级签名双双打叉(sha256 不符 + 验签不过)", async () => {
105
+ it("then 包级签名打叉(signature_mismatch:sha256 重算后与签名覆盖值不符)", async () => {
122
106
  const root = await seed();
123
107
  const tgz = join(root, "cdn", "demo-solution-0.1.0.tgz");
124
108
  const bytes = Buffer.from(readFileSync(tgz));
@@ -128,7 +112,6 @@ describe("given 包体被改了一个字节 When 离线验包", () => {
128
112
  const result = await verifyMarket({ projectRoot: root });
129
113
 
130
114
  expect(result.passed).toBe(false);
131
- expect(itemOf(result, "完整性")?.ok).toBe(false);
132
115
  expect(itemOf(result, "包级签名")?.ok).toBe(false);
133
116
  });
134
117
  });
@@ -136,200 +119,55 @@ describe("given 包体被改了一个字节 When 离线验包", () => {
136
119
  describe("given 包级签名被删掉 When 离线验包", () => {
137
120
  it("then 包级签名打叉并给出 sig_missing 口径的指引", async () => {
138
121
  const root = await seed();
139
- rmSync(join(root, "cdn", "demo-solution-0.1.0.tgz.sig"));
140
-
141
- const result = await verifyMarket({ projectRoot: root });
142
-
143
- expect(itemOf(result, "包级签名")?.ok).toBe(false);
144
- expect(itemOf(result, "包级签名")?.detail).toMatch(/sig|签名/);
145
- });
146
- });
147
-
148
- describe("given catalog.yaml 被手改过 When 离线验包", () => {
149
- it("then 索引签名打叉 index_sig_mismatch(索引签名是完整性层,篡改必须可见)", async () => {
150
- const root = await seed();
151
- const catalog = join(root, "cdn", "catalog.yaml");
152
- writeFileSync(catalog, `${readFileSync(catalog, "utf-8")}\n# tampered\n`);
153
-
154
- const result = await verifyMarket({ projectRoot: root });
155
-
156
- expect(result.passed).toBe(false);
157
- expect(sigItems(result).some(c => !c.ok && c.detail.includes("catalog.yaml"))).toBe(true);
158
- // REG-CS1:篡改与缺失必须可分(前者是安全事件,后者是发布流程漏了)
159
- expect(sigItems(result).find(c => c.detail.includes("catalog.yaml"))?.code).toBe("index_sig_mismatch");
160
- });
161
- });
162
-
163
- describe("given 分片正文被手改过 When 离线验包", () => {
164
- it("then 该片索引签名打叉 index_sig_mismatch,且完整性同步打叉(sha256 声明已不可信)", async () => {
165
- const root = await seed();
166
- const shard = join(root, "cdn", "shards", "1.yaml");
167
- // 把声明里的 sha256 改掉:片签名失配 + 索引声明的摘要与包体不符,两层都须看见。
168
- // 篡改值取带字母的十六进制(f…f)——纯数字串会被 YAML 解析成 number,那是另一种形态的坏索引
169
- const before = readFileSync(shard, "utf-8");
170
- const text = before.replace(/sha256: [0-9a-f]{64}/, `sha256: ${"f".repeat(64)}`);
171
- expect(text).not.toEqual(before); // 确认替换命中(夹具确有该字段)
172
- writeFileSync(shard, text);
173
-
174
- const result = await verifyMarket({ projectRoot: root });
175
-
176
- expect(result.passed).toBe(false);
177
- expect(sigItems(result).find(c => c.detail.includes("shards/1.yaml"))?.code).toBe("index_sig_mismatch");
178
- expect(itemOf(result, "完整性")?.ok).toBe(false);
179
- });
180
- });
181
-
182
- describe("given 索引缺 .sig When 离线验包", () => {
183
- it("then 索引签名打叉 index_sig_missing 并给两步补签提示(不再「运行期容忍」)", async () => {
184
- const root = await seed();
185
- rmSync(join(root, "cdn", "catalog.yaml.sig"));
186
- rmSync(join(root, "cdn", "shards", "1.yaml.sig"));
187
-
188
- const result = await verifyMarket({ projectRoot: root });
189
-
190
- expect(result.passed).toBe(false);
191
- const bad = sigItems(result).filter(c => !c.ok);
192
- expect(bad.length).toBeGreaterThanOrEqual(2);
193
- expect(bad.every(c => c.code === "index_sig_missing")).toBe(true);
194
- expect(bad[0]?.detail).toContain("ojm market build-index");
195
- expect(bad[0]?.detail).toContain("ojm publish");
196
- });
197
- });
198
-
199
- describe("given 只缺 catalog 的 .sig When 离线验包", () => {
200
- it("then catalog 项打叉 index_sig_missing、分片项不受牵连(按文件分列的意义)", async () => {
201
- const root = await seed();
202
- rmSync(join(root, "cdn", "catalog.yaml.sig"));
203
-
204
- const result = await verifyMarket({ projectRoot: root });
205
-
206
- const sigs = sigItems(result);
207
- expect(sigs.find(c => c.detail.includes("catalog.yaml"))?.code).toBe("index_sig_missing");
208
- expect(sigs.find(c => c.detail.includes("shards/1.yaml"))?.ok).toBe(true);
209
- });
210
- });
211
-
212
- describe("given catalog 正文代次与签名代次不一致 When 离线验包", () => {
213
- it("then 同代校验打叉报 index_generation_mismatch 并提示「源正在重建索引」(不是「索引被篡改」)", async () => {
214
- const root = await seed();
215
- // 改了正文没换签名:正文自称第 7 代、签名还是第 1 代
216
- const catalog = join(root, "cdn", "catalog.yaml");
217
- writeFileSync(catalog, readFileSync(catalog, "utf-8").replace(/^seq: \d+$/m, "seq: 7"), "utf-8");
218
-
219
- const result = await verifyMarket({ projectRoot: root });
220
-
221
- expect(result.passed).toBe(false);
222
- const item = itemOf(result, "索引同代");
223
- expect(item?.ok).toBe(false);
224
- expect(item?.code).toBe("index_generation_mismatch");
225
- expect(item?.detail).toContain("源正在重建索引,请稍后重试");
226
- expect(item?.detail).not.toContain("被篡改");
227
- });
228
- });
229
-
230
- describe("given catalog 钉的片代次与分片签名不符 When 离线验包(红队 F2 新形态:新目录钉旧片)", () => {
231
- it("then 同代校验打叉 index_generation_mismatch(拼不进,按运维事故提示重试)", async () => {
232
- const root = await seed();
233
- const cdn = join(root, "cdn");
234
- // 分片签名重签成第 2 代(签名本身有效),catalog 仍钉第 1 代——「写到一半」的中间态
235
- const shardText = recomputeIndex(readFileSync(join(cdn, "shards", "1.yaml"), "utf-8"));
236
- writeFileSync(
237
- join(cdn, "shards", "1.yaml.sig"),
238
- `${JSON.stringify(signIndex("shard", 2, shardText, testPem(), KEY_ID), null, 2)}\n`,
239
- "utf-8",
240
- );
241
-
242
- const result = await verifyMarket({ projectRoot: root });
243
-
244
- const item = itemOf(result, "索引同代");
245
- expect(item?.ok).toBe(false);
246
- expect(item?.code).toBe("index_generation_mismatch");
247
- expect(item?.detail).toContain("源正在重建索引,请稍后重试");
248
- });
249
- });
250
-
251
- describe("given 分片正文没了但 catalog 还钉着它 When 离线验包", () => {
252
- it("then 同代校验打叉 index_generation_mismatch(代次不可判定即拒,fail-closed)", async () => {
253
- const root = await seed();
254
- rmSync(join(root, "cdn", "shards", "1.yaml"));
255
- rmSync(join(root, "cdn", "shards", "1.yaml.sig"));
256
-
257
- const result = await verifyMarket({ projectRoot: root });
258
-
259
- const item = itemOf(result, "索引同代");
260
- expect(item?.ok).toBe(false);
261
- expect(item?.code).toBe("index_generation_mismatch");
262
- expect(item?.detail).toContain("正文缺失");
263
- });
264
- });
265
-
266
- describe("given 索引的 .sig 是旧格式(不带 seq)When 离线验包", () => {
267
- it("then 索引签名打叉并报 index_sig_mismatch(签名未覆盖代次 = 挡不住新旧拼接)", async () => {
268
- const root = await seed();
269
- const sigPath = join(root, "cdn", "catalog.yaml.sig");
270
- const sig = JSON.parse(readFileSync(sigPath, "utf-8")) as Record<string, unknown>;
271
- delete sig.seq;
272
- writeFileSync(sigPath, JSON.stringify(sig), "utf-8");
122
+ const tgz = join(root, "cdn", "demo-solution-0.1.0.tgz");
123
+ rmSync(`${tgz}.sig`);
273
124
 
274
125
  const result = await verifyMarket({ projectRoot: root });
275
126
 
276
- expect(sigItems(result).find(c => c.detail.includes("catalog.yaml"))?.code).toBe("index_sig_mismatch");
127
+ const check = itemOf(result, "包级签名");
128
+ expect(check?.ok).toBe(false);
129
+ expect(check?.code).toBe("sig_missing");
130
+ expect(check?.detail).toContain("ojm market publish");
277
131
  });
278
132
  });
279
133
 
280
- describe("given 索引已过自声明的失效时刻 When 离线验包", () => {
281
- it("then 索引时效打叉报 index_expired(REG-CS2:与站点安装同一判定,CLI/API 一致)", async () => {
134
+ describe("given 索引文件被手改/删除 When 离线验包(A6:索引面已退役)", () => {
135
+ it("then 校验结果不受影响——目录真源在站点 DB,盘上索引文件已非消费面", async () => {
282
136
  const root = await seed();
283
- const catalogPath = join(root, "cdn", "catalog.yaml");
284
- const doc = parse(readFileSync(catalogPath, "utf-8")) as Record<string, unknown>;
285
- doc.issued_at = 1;
286
- doc.expires_at = 2;
287
- writeFileSync(catalogPath, stringify(doc), "utf-8");
137
+ // 即便盘上残留旧索引文件(升级未清理),verify 也绝不再消费它们
138
+ writeFileSync(join(root, "cdn", "catalog.yaml"), "tampered: true\n");
288
139
 
289
140
  const result = await verifyMarket({ projectRoot: root });
290
141
 
291
- expect(itemOf(result, "索引时效")?.ok).toBe(false);
292
- expect(itemOf(result, "索引时效")?.code).toBe("index_expired");
142
+ expect(result.passed).toBe(true);
143
+ expect(result.checks.some(c => c.item === "索引签名")).toBe(false);
144
+ expect(result.checks.some(c => c.item === "索引同代")).toBe(false);
145
+ expect(result.checks.some(c => c.item === "索引时效")).toBe(false);
293
146
  });
294
147
  });
295
148
 
296
- describe("given 索引不带时效字段(旧 ojm 产物)When 离线验包", () => {
297
- it("then 索引时效打叉(不可判定即按过期处理,不默认当新鲜)", async () => {
149
+ describe("given 同方案两个版本的 0001 迁移不一致 When 离线验包", () => {
150
+ it("then manifest 结构项打叉(升级链会报迁移缺失,须只增不改)", async () => {
298
151
  const root = await seed();
299
- const catalogPath = join(root, "cdn", "catalog.yaml");
300
- const doc = parse(readFileSync(catalogPath, "utf-8")) as Record<string, unknown>;
301
- delete doc.issued_at;
302
- delete doc.expires_at;
303
- writeFileSync(catalogPath, stringify(doc), "utf-8");
152
+ await publishPackage({ projectRoot: root, fixture: "demo-solution@0.2.0", id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
153
+ // 篡改 0.2.0 包的 0001 迁移字节(解包重打 + 重签,保持包可解)
154
+ const tgz = join(root, "cdn", "demo-solution-0.2.0.tgz");
155
+ const staging = mkdtempSync(join(tmpdir(), "ojm-verify-mig-"));
156
+ for (const e of readTgz(new Uint8Array(readFileSync(tgz)))) {
157
+ if (e.name.endsWith("/"))
158
+ continue;
159
+ const abs = join(staging, e.name);
160
+ mkdirSync(dirname(abs), { recursive: true });
161
+ writeFileSync(abs, /\/migrations\/0001[^/]*\.sql$/.test(e.name) ? `${e.content}-- tampered\n` : e.content);
162
+ }
163
+ await createDeterministicTgz(staging, tgz);
164
+ signCommand(tgz, { id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
165
+ rmSync(staging, { recursive: true, force: true });
304
166
 
305
167
  const result = await verifyMarket({ projectRoot: root });
306
168
 
307
- expect(itemOf(result, "索引时效")?.ok).toBe(false);
308
- expect(itemOf(result, "索引时效")?.code).toBe("index_expired");
309
- });
310
- });
311
-
312
- describe("given 落了 tgz 但没重建索引 When 带 --rebuild-check 验包", () => {
313
- it("then 报漂移:索引缺该包条目", async () => {
314
- const root = newProject();
315
- await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
316
- await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
317
- await publishPackage({ projectRoot: root, fixture: "other-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
318
-
319
- const result = await verifyMarket({ projectRoot: root, rebuildCheck: true });
320
-
321
- expect(result.drift?.length ?? 0).toBeGreaterThan(0);
322
- expect(result.drift?.join(" ")).toMatch(/other-solution/);
323
- });
324
- });
325
-
326
- describe("given 索引声明了某个版本但盘上包没了 When 带 --rebuild-check 验包", () => {
327
- it("then 报反向漂移:索引多条目(只查正向会让「答应有、实际没有」漏过去)", async () => {
328
- const root = await seed();
329
- rmSync(join(root, "cdn", "demo-solution-0.1.0.tgz"));
330
-
331
- const result = await verifyMarket({ projectRoot: root, rebuildCheck: true });
332
-
333
- expect(result.drift?.join(" ")).toMatch(/索引多条目:demo-solution@0\.1\.0/);
169
+ const mig = result.checks.find(c => c.item === "manifest 结构" && c.detail.includes("0001 迁移在版本"));
170
+ expect(mig?.ok).toBe(false);
171
+ expect(mig?.detail).toContain("0.2.0");
334
172
  });
335
173
  });