@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
@@ -0,0 +1,661 @@
1
+ // BDD:CDN 上传端点(Phase A v4 §二:签名推送 + pair 约束 + DB 目录真源写行)。
2
+ // POST /api/oj-market/cdn/upload?name=<file> —— 发布方(ojm market publish 远端模式)经
3
+ // X-OJM-Key-Id / X-OJM-Signature 签名推送发布 .tgz + .sig 旁车;pair 约束(sig 先 tgz 后),
4
+ // 处理顺序钉死:查库比对 → staging 解包抽 manifest(体积闸)→ 原子落盘 → db.tx 写行。
5
+ // 鉴权序:先验头(403 fail-fast,不消费 body)→ 再消费 body(413)→ 服务端重算 sha256
6
+ // 重构 canonical 验签(换字节/跨站重放 → 401)。
7
+ /* eslint-disable test/prefer-lowercase-title -- 端点动词/路径是验收锚点,保留大写(market-api.spec 同约定) */
8
+ import type { FsFace } from "../../src/app/api/_platform/_lifecycle/atomic";
9
+ import type { ExecFace } from "../../src/app/api/_platform/_lifecycle/steps/extract";
10
+ import type { CdnUploadEnv } from "../../src/oj-market/api/cdn/api";
11
+ import { createHash } from "node:crypto";
12
+ import * as fsp from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+ import { defaultTestKey, nodeVerifyEd25519, signedArchiveFor } from "../../../src/lifecycle/__tests__/helpers";
17
+ import { generateKeyPair } from "../../../src/lifecycle/keygen";
18
+ import { signCanonical } from "../../../src/lifecycle/sign";
19
+ import { createTrustStore } from "../../src/app/api/_platform/_lifecycle/trust";
20
+ import { installDb } from "../fixture";
21
+
22
+ const g = globalThis as any;
23
+
24
+ function hexToBytes(hex: string): Uint8Array {
25
+ const out = new Uint8Array(hex.length / 2);
26
+ for (let i = 0; i < out.length; i++)
27
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
28
+ return out;
29
+ }
30
+
31
+ /** 上传载荷字节(内容任意——exec 桩按 argv 形状响应,真 sha256 由客户端先算好签名) */
32
+ const ARCHIVE = new TextEncoder().encode("demo-archive-bytes\n");
33
+ const SHA256 = createHash("sha256").update(ARCHIVE).digest("hex");
34
+ /** 共享测试 key(播种进信任库;无私钥入仓) */
35
+ const TEST_KEY = defaultTestKey();
36
+ const KEY_ID = "test.publisher";
37
+ /** 本站点 registry 身份(canonical 首段) */
38
+ const REGISTRY = "test.registry";
39
+ const FILE = "demo-solution-0.1.0.tgz";
40
+ const SIG_FILE = `${FILE}.sig`;
41
+ const SIG_BYTES = new TextEncoder().encode(JSON.stringify(signedArchiveFor(ARCHIVE, "demo-solution", "0.1.0", { keyPair: TEST_KEY }).sig));
42
+
43
+ /** 上传签名面:canonical `cdn-upload:<registry_id>:<name>:<sha256(bytes)>`(与端点重算口径逐字一致) */
44
+ function uploadSignature(name: string, bytes: Uint8Array, opts: { registryId?: string, key?: typeof TEST_KEY } = {}): Record<string, string> {
45
+ const key = opts.key ?? TEST_KEY;
46
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
47
+ const canonical = `cdn-upload:${opts.registryId ?? REGISTRY}:${name}:${sha256}`;
48
+ return { "x-ojm-key-id": KEY_ID, "x-ojm-signature": signCanonical(key.privateKeyPem, canonical) };
49
+ }
50
+
51
+ /** 包内嵌 manifest(tar 桩解包写出;name/version 与文件名逐字一致) */
52
+ const MANIFEST = {
53
+ name: "demo-solution",
54
+ version: "0.1.0",
55
+ summary: "演示方案",
56
+ description: "演示方案描述",
57
+ author: "oj-module",
58
+ license: "MIT",
59
+ web: { entry: "modules/demo-solution/0.1.0/entry.js" },
60
+ };
61
+
62
+ let root: string;
63
+ let cdnDir: string;
64
+ let staging: string;
65
+ let trustPath: string;
66
+ let dbo: any;
67
+
68
+ let last: { status: number, msg?: string, data?: unknown } | null = null;
69
+
70
+ function nodeFsFace(): FsFace {
71
+ return {
72
+ writeFile: (path, data) => fsp.writeFile(path, data),
73
+ readFile: path => fsp.readFile(path),
74
+ mkdir: (path, opts) => fsp.mkdir(path, opts),
75
+ remove: (path, opts) => fsp.rm(path, opts),
76
+ rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
77
+ copyFile: (src, dest) => fsp.copyFile(src, dest),
78
+ readdir: path => fsp.readdir(path),
79
+ };
80
+ }
81
+
82
+ /** tar 桩:-tvzf 回 verbose 清单,-xzf 写出 manifest 夹具(dest = argv[4]) */
83
+ function uploadExec(overrides: { listing?: string, manifest?: unknown } = {}): ExecFace {
84
+ return {
85
+ run: async (argv) => {
86
+ if (argv[0] === "tar" && argv[1] === "-tvzf")
87
+ return { code: 0, stdout: overrides.listing ?? "-rw-r--r-- 0/0 200 2026-01-01 00:00 manifest.yaml\n", stderr: "" };
88
+ if (argv[0] === "tar" && argv[1] === "-xzf") {
89
+ await fsp.mkdir(argv[4]!, { recursive: true });
90
+ await fsp.writeFile(join(argv[4]!, "manifest.yaml"), JSON.stringify(overrides.manifest ?? MANIFEST));
91
+ return { code: 0, stdout: "", stderr: "" };
92
+ }
93
+ return { code: 0, stdout: "", stderr: "" };
94
+ },
95
+ };
96
+ }
97
+
98
+ function makeEnv(overrides: Partial<CdnUploadEnv> = {}): CdnUploadEnv {
99
+ return {
100
+ cdnDir,
101
+ staging,
102
+ trustPath,
103
+ registryId: REGISTRY,
104
+ configPath: join(root, "config.yaml"),
105
+ exec: uploadExec(),
106
+ sha256Hex: d => createHash("sha256").update(d).digest("hex"),
107
+ verifyEd25519: nodeVerifyEd25519,
108
+ ...overrides,
109
+ };
110
+ }
111
+
112
+ /** 组装一次请求:name=查询参数 + 头 + 单文件字节(oj http 面:files 元数据 + http.file(i) 取字节) */
113
+ function httpFor(name: string, bytes: Uint8Array, headers: Record<string, string> = uploadSignature(name, bytes)) {
114
+ return { query: { name }, headers, files: [{}], file: async () => bytes };
115
+ }
116
+
117
+ /** 先推 sig 旁车(pair 前置),返回可继续推 tgz 的请求环境 */
118
+ async function pushSig(api: any, opts: { key?: typeof TEST_KEY, registryId?: string, sigBytes?: Uint8Array } = {}) {
119
+ const sigBytes = opts.sigBytes ?? SIG_BYTES;
120
+ g.http = httpFor(SIG_FILE, sigBytes, uploadSignature(SIG_FILE, sigBytes, { key: opts.key, registryId: opts.registryId }));
121
+ await api.upload.post();
122
+ }
123
+
124
+ /** 以指定 keyId/key 推旁车(投毒场景:rival key 也在信任库,推送头身份须与之对应) */
125
+ function pushSigAs(api: any, keyId: string, key: { privateKeyPem: string }, sigBytes: Uint8Array): Promise<void> {
126
+ const sha256 = createHash("sha256").update(sigBytes).digest("hex");
127
+ const canonical = `cdn-upload:${REGISTRY}:${SIG_FILE}:${sha256}`;
128
+ g.http = { query: { name: SIG_FILE }, headers: { "x-ojm-key-id": keyId, "x-ojm-signature": signCanonical(key.privateKeyPem, canonical) }, files: [{}], file: async () => sigBytes };
129
+ return api.upload.post();
130
+ }
131
+
132
+ /** 信任库加录一把 key(rivals 场景用) */
133
+ async function trustKey(keyId: string, publicKeyHex: string) {
134
+ await createTrustStore(trustPath, { audit: () => {}, fingerprintOf: pk => createHash("sha256").update(hexToBytes(pk)).digest("hex") }).add(keyId, publicKeyHex);
135
+ }
136
+
137
+ beforeEach(async () => {
138
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-cdn-upload-"));
139
+ cdnDir = join(root, "cdn");
140
+ staging = join(root, "modules", ".staging-upload");
141
+ trustPath = join(root, ".solutions", "trusted-keys.yaml");
142
+ await fsp.mkdir(cdnDir, { recursive: true });
143
+ await fsp.writeFile(join(root, "config.yaml"), "vars:\n other: 1\n");
144
+ g.fs = nodeFsFace();
145
+ dbo = installDb();
146
+ dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
147
+ // 播种信任库(上传签名与包级签名同一把 TEST_KEY)
148
+ await createTrustStore(trustPath, { audit: () => {}, fingerprintOf: pk => createHash("sha256").update(hexToBytes(pk)).digest("hex") }).add(KEY_ID, TEST_KEY.publicKeyHex);
149
+ g.yaml = { parse: async (t: string) => JSON.parse(t) };
150
+ g.http = {};
151
+ g.json = {
152
+ ok: (data: unknown) => {
153
+ last = { status: 200, data };
154
+ },
155
+ fail: (status: number, msg: string) => {
156
+ last = { status, msg };
157
+ },
158
+ header: () => {},
159
+ };
160
+ last = null;
161
+ });
162
+
163
+ afterEach(async () => {
164
+ g.fs = undefined;
165
+ g.http = undefined;
166
+ g.json = undefined;
167
+ g.yaml = undefined;
168
+ g.db = undefined;
169
+ await fsp.rm(root, { recursive: true, force: true });
170
+ });
171
+
172
+ describe("POST upload(签名鉴权:canonical cdn-upload:<registry_id>:<name>:<sha256>,服务端重算)", () => {
173
+ it("信任 key + 有效签名 + pair 齐 → 200:tgz 落 cdn/、market_release 行落库(published_at_source='upload')", async () => {
174
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
175
+ const api = createCdnUploadApi(makeEnv());
176
+ await pushSig(api);
177
+ expect(last?.status).toBe(200);
178
+ g.http = httpFor(FILE, ARCHIVE);
179
+ await api.upload.post();
180
+ expect(last?.status).toBe(200);
181
+ const data = last?.data as Record<string, unknown>;
182
+ expect(data).toMatchObject({ name: "demo-solution", version: "0.1.0", sha256: SHA256, size: ARCHIVE.byteLength, key_id: KEY_ID });
183
+ // 字节落位
184
+ const onDisk = createHash("sha256").update(await fsp.readFile(join(cdnDir, FILE))).digest("hex");
185
+ expect(onDisk).toBe(SHA256);
186
+ // 行落库:product+version 唯一、签名三字段与包内嵌 manifest 抽取物入库
187
+ const rows = dbo.prepare("SELECT * FROM market_release WHERE product=? AND version=?").all("demo-solution", "0.1.0");
188
+ expect(rows).toHaveLength(1);
189
+ expect(rows[0]).toMatchObject({
190
+ key_id: KEY_ID,
191
+ sha256: SHA256,
192
+ size: ARCHIVE.byteLength,
193
+ published_at_source: "upload",
194
+ yanked: 0,
195
+ sig_alg: "Ed25519",
196
+ sig_public_key: TEST_KEY.publicKeyHex,
197
+ });
198
+ expect(String(rows[0].sig_value)).toMatch(/^[A-Z0-9+/]+={0,2}$/i);
199
+ expect(JSON.parse(rows[0].manifest)).toMatchObject({ name: "demo-solution", version: "0.1.0" });
200
+ });
201
+
202
+ it("parity 三方一致(v4 §特性):推送响应 ↔ DB 行 ↔ detail API Form B 条目——同一真源零漂移", async () => {
203
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
204
+ const api = createCdnUploadApi(makeEnv());
205
+ await pushSig(api);
206
+ g.http = httpFor(FILE, ARCHIVE);
207
+ await api.upload.post();
208
+ expect(last?.status).toBe(200);
209
+ const pushed = last?.data as Record<string, unknown>;
210
+ const row = dbo.prepare("SELECT * FROM market_release WHERE product=? AND version=?").get("demo-solution", "0.1.0");
211
+ // detail API(安装解析数据源;env 不在 detail 路径上,空对象即可——构造仅闭合引用)
212
+ const { createMarketApi } = await import("../../src/oj-market/api/api");
213
+ const market = createMarketApi({} as never);
214
+ g.http = { query: { name: "demo-solution" } };
215
+ await market.catalog.detail.get();
216
+ expect(last?.status).toBe(200);
217
+ const detail = last?.data as { product: string, latest: string, versions: Array<Record<string, unknown>> };
218
+ const formB = detail.versions[0]!.archive as Record<string, unknown>;
219
+ const sig = formB.signature as Record<string, unknown>;
220
+ // 三方逐字段一致:sha256 / size / key_id(签名三字段 + registry_id 经 detail 合成面)
221
+ expect(pushed).toMatchObject({ sha256: SHA256, size: ARCHIVE.byteLength, key_id: KEY_ID });
222
+ expect(row).toMatchObject({ sha256: SHA256, size: ARCHIVE.byteLength, key_id: KEY_ID });
223
+ expect(formB).toMatchObject({ file: FILE, sha256: SHA256, size: ARCHIVE.byteLength });
224
+ expect(sig).toMatchObject({ alg: "Ed25519", key_id: KEY_ID, canon: "v2", registry_id: REGISTRY });
225
+ expect(sig.value).toBe(row.sig_value);
226
+ // CLI 侧推送形态(remote-push.ts)与本端点验签口径同一条 canonical 公式——
227
+ // 跨进程 wire parity 由 remote-push.test(客户端公式)+ 本 spec(服务端公式)夹住
228
+ });
229
+
230
+ it("未信任 key → 403(附 trust add 指引);fail-fast 不消费 body", async () => {
231
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
232
+ const api = createCdnUploadApi(makeEnv());
233
+ const foreign = generateKeyPair();
234
+ g.http = httpFor(FILE, ARCHIVE, { ...uploadSignature(FILE, ARCHIVE, { key: foreign }), "x-ojm-key-id": "foreign.publisher" });
235
+ const consume = vi.fn(async () => ARCHIVE);
236
+ g.http.file = consume;
237
+ await api.upload.post();
238
+ expect(last?.status).toBe(403);
239
+ expect(String(last?.msg)).toContain("trust");
240
+ expect(consume).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it("换字节 → 401:服务端从实收字节重算 sha256 重构 canonical,声明值与实收不符即拒签", async () => {
244
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
245
+ const api = createCdnUploadApi(makeEnv());
246
+ // 对「别的字节」签名,却上传 ARCHIVE——重算后 canonical 不匹配
247
+ const other = new TextEncoder().encode("other-bytes\n");
248
+ g.http = httpFor(FILE, ARCHIVE, uploadSignature(FILE, other));
249
+ await api.upload.post();
250
+ expect(last?.status).toBe(401);
251
+ // 零痕迹:cdn/ 无文件、库无行
252
+ expect(await fsp.readdir(cdnDir)).toEqual([]);
253
+ expect(dbo.prepare("SELECT COUNT(*) c FROM market_release").all()[0].c).toBe(0);
254
+ });
255
+
256
+ it("registry_id 不符(跨站重放:对别的站点身份签的 canonical)→ 401", async () => {
257
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
258
+ const api = createCdnUploadApi(makeEnv());
259
+ g.http = httpFor(FILE, ARCHIVE, uploadSignature(FILE, ARCHIVE, { registryId: "other.registry" }));
260
+ await api.upload.post();
261
+ expect(last?.status).toBe(401);
262
+ });
263
+
264
+ it("缺 X-OJM 头 → 401(签名制不经账号口令,企微用户无障碍;无角色检查)", async () => {
265
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
266
+ const api = createCdnUploadApi(makeEnv());
267
+ g.http = httpFor(FILE, ARCHIVE, {});
268
+ await api.upload.post();
269
+ expect(last?.status).toBe(401);
270
+ });
271
+
272
+ it("registry_id 未配置(env 与 vars.registry_id 俱缺)→ 503 指向设置项", async () => {
273
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
274
+ const api = createCdnUploadApi(makeEnv({ registryId: undefined }));
275
+ await pushSig(api);
276
+ g.http = httpFor(FILE, ARCHIVE);
277
+ await api.upload.post();
278
+ expect(last?.status).toBe(503);
279
+ expect(String(last?.msg)).toContain("registry_id");
280
+ });
281
+ });
282
+
283
+ describe("POST upload(name 所有权:product → 首发 key_id 绑定不可改,v4 §一)", () => {
284
+ it("非首发 key 推既有 name 新版本 → 409;首发 key 推新版本 → 200", async () => {
285
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
286
+ const api = createCdnUploadApi(makeEnv());
287
+ // 首发:demo-solution@0.1.0(key = test.publisher)
288
+ await pushSig(api);
289
+ g.http = httpFor(FILE, ARCHIVE);
290
+ await api.upload.post();
291
+ expect(last?.status).toBe(200);
292
+ // 另一位受信发布方(入信任库;所有权仍绑定首发 key)
293
+ const other = generateKeyPair();
294
+ await createTrustStore(trustPath, { audit: () => {} }).add("other.publisher", other.publicKeyHex);
295
+ const file2 = "demo-solution-0.2.0.tgz";
296
+ const sig2 = new TextEncoder().encode(JSON.stringify(signedArchiveFor(ARCHIVE, "demo-solution", "0.2.0", { keyPair: other, keyId: "other.publisher" }).sig));
297
+ g.http = httpFor(`${file2}.sig`, sig2, { ...uploadSignature(`${file2}.sig`, sig2, { key: other }), "x-ojm-key-id": "other.publisher" });
298
+ await api.upload.post();
299
+ expect(last?.status).toBe(200); // sig 旁车本身可替任意 name 预置(tgz 闸在写行处)
300
+ g.http = httpFor(file2, ARCHIVE, { ...uploadSignature(file2, ARCHIVE, { key: other }), "x-ojm-key-id": "other.publisher" });
301
+ await api.upload.post();
302
+ expect(last?.status).toBe(409);
303
+ expect(String(last?.msg)).toContain("所有权");
304
+ // 首发 key 推新版本 → 200(manifest 版本随文件名)
305
+ const sig3 = new TextEncoder().encode(JSON.stringify(signedArchiveFor(ARCHIVE, "demo-solution", "0.2.0", { keyPair: TEST_KEY }).sig));
306
+ await fsp.writeFile(join(cdnDir, `${file2}.sig`), sig3);
307
+ const api2 = createCdnUploadApi(makeEnv({ exec: uploadExec({ manifest: { ...MANIFEST, version: "0.2.0" } }) }));
308
+ g.http = httpFor(file2, ARCHIVE);
309
+ await api2.upload.post();
310
+ expect(last?.status).toBe(200);
311
+ });
312
+
313
+ it("遗留行 key_id 为 NULL(syncReleases 时代)不建立所有权:首个上传采用并绑定", async () => {
314
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
315
+ const api = createCdnUploadApi(makeEnv());
316
+ dbo.prepare("INSERT INTO market_release (product,version,key_id,sha256,size,published_at,published_at_source,yanked,first_seen_at) VALUES (?,?,NULL,NULL,NULL,?,'index',0,?)")
317
+ .run("demo-solution", "0.0.9", 1000, 1000);
318
+ await pushSig(api);
319
+ g.http = httpFor(FILE, ARCHIVE);
320
+ await api.upload.post();
321
+ expect(last?.status).toBe(200);
322
+ const row = dbo.prepare("SELECT key_id FROM market_release WHERE product=? AND version=?").get("demo-solution", "0.1.0");
323
+ expect(row.key_id).toBe(KEY_ID);
324
+ });
325
+ });
326
+
327
+ describe("POST upload(文件名白名单 + 体积 + 限速)", () => {
328
+ it.each([
329
+ ["../evil-0.1.0.tgz", "路径逃逸"],
330
+ ["/abs/evil-0.1.0.tgz", "绝对路径"],
331
+ ["back\\slash-0.1.0.tgz", "反斜杠"],
332
+ ["demo-solution-0.1.0.zip", "扩展名"],
333
+ ["demo-solution.tgz", "缺版本段"],
334
+ ["Demo-0.1.0.tgz", "大写开头(C2-01 小写锚)"],
335
+ ])("非法文件名 %s → 400(%s)", async (name) => {
336
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
337
+ const api = createCdnUploadApi(makeEnv());
338
+ g.http = httpFor(name, ARCHIVE, uploadSignature(name, ARCHIVE));
339
+ await api.upload.post();
340
+ expect(last?.status).toBe(400);
341
+ expect(await fsp.readdir(cdnDir)).toEqual([]);
342
+ });
343
+
344
+ it("name 缺失 → 400", async () => {
345
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
346
+ const api = createCdnUploadApi(makeEnv());
347
+ g.http = { query: {}, headers: uploadSignature(FILE, ARCHIVE), files: [{}], file: async () => ARCHIVE };
348
+ await api.upload.post();
349
+ expect(last?.status).toBe(400);
350
+ });
351
+
352
+ it("大文件 → 413(MAX_PACKAGE_BYTES 闸)", async () => {
353
+ const { createCdnUploadApi, MAX_UPLOAD_BYTES } = await import("../../src/oj-market/api/cdn/api");
354
+ const big = new Uint8Array(MAX_UPLOAD_BYTES + 1);
355
+ const api = createCdnUploadApi(makeEnv());
356
+ g.http = httpFor(FILE, big);
357
+ await api.upload.post();
358
+ expect(last?.status).toBe(413);
359
+ expect(await fsp.readdir(cdnDir)).toEqual([]);
360
+ });
361
+
362
+ it("限速按 key_id 生效:超限 → 429", async () => {
363
+ const { createCdnUploadApi, UPLOAD_RATE_LIMIT } = await import("../../src/oj-market/api/cdn/api");
364
+ const api = createCdnUploadApi(makeEnv());
365
+ for (let i = 0; i < UPLOAD_RATE_LIMIT.limit; i++) {
366
+ g.http = httpFor(SIG_FILE, SIG_BYTES);
367
+ await api.upload.post();
368
+ expect(last?.status).toBe(200);
369
+ }
370
+ g.http = httpFor(SIG_FILE, SIG_BYTES);
371
+ await api.upload.post();
372
+ expect(last?.status).toBe(429);
373
+ });
374
+ });
375
+
376
+ describe("POST upload(pair 约束:sig 先 tgz 后)", () => {
377
+ it("tgz 无 sig 旁车 → 409", async () => {
378
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
379
+ const api = createCdnUploadApi(makeEnv());
380
+ g.http = httpFor(FILE, ARCHIVE);
381
+ await api.upload.post();
382
+ expect(last?.status).toBe(409);
383
+ expect(String(last?.msg)).toContain("sig");
384
+ expect(await fsp.readdir(cdnDir)).toEqual([]);
385
+ });
386
+
387
+ it.each([
388
+ ["缺 alg", { key_id: KEY_ID, public_key: TEST_KEY.publicKeyHex, value: "AA==", canon: "v2" }],
389
+ ["alg 非 Ed25519", { alg: "RSA", key_id: KEY_ID, public_key: TEST_KEY.publicKeyHex, value: "AA==", canon: "v2" }],
390
+ ["canon 非 v2", { alg: "Ed25519", key_id: KEY_ID, public_key: TEST_KEY.publicKeyHex, value: "AA==", canon: "v1" }],
391
+ ["public_key 非 64hex", { alg: "Ed25519", key_id: KEY_ID, public_key: "zz", value: "AA==", canon: "v2" }],
392
+ ["value 非 base64", { alg: "Ed25519", key_id: KEY_ID, public_key: TEST_KEY.publicKeyHex, value: "!!not-b64!!", canon: "v2" }],
393
+ ["缺 key_id", { alg: "Ed25519", public_key: TEST_KEY.publicKeyHex, value: "AA==", canon: "v2" }],
394
+ ])("sig 五字段非法(%s)→ 400", async (_label, sig) => {
395
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
396
+ const api = createCdnUploadApi(makeEnv());
397
+ const bad = new TextEncoder().encode(JSON.stringify(sig));
398
+ g.http = httpFor(SIG_FILE, bad, uploadSignature(SIG_FILE, bad));
399
+ await api.upload.post();
400
+ expect(last?.status).toBe(400);
401
+ expect(await fsp.readdir(cdnDir)).toEqual([]);
402
+ });
403
+
404
+ it("同字节重传 → 200 幂等(行数不变、published_at 不改写)", async () => {
405
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
406
+ const api = createCdnUploadApi(makeEnv());
407
+ await pushSig(api);
408
+ g.http = httpFor(FILE, ARCHIVE);
409
+ await api.upload.post();
410
+ expect(last?.status).toBe(200);
411
+ const first = dbo.prepare("SELECT published_at FROM market_release WHERE product=? AND version=?").get("demo-solution", "0.1.0");
412
+ // 重传(pair 亦齐,sig 文件已在)
413
+ g.http = httpFor(FILE, ARCHIVE);
414
+ await api.upload.post();
415
+ expect(last?.status).toBe(200);
416
+ expect(dbo.prepare("SELECT COUNT(*) c FROM market_release").all()[0].c).toBe(1);
417
+ const second = dbo.prepare("SELECT published_at FROM market_release WHERE product=? AND version=?").get("demo-solution", "0.1.0");
418
+ expect(second.published_at).toBe(first.published_at);
419
+ });
420
+
421
+ it("幂等自愈:行在而 tgz 文件丢失 → 同字节重传补齐文件(含 sig 缺失一并补)", async () => {
422
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
423
+ const api = createCdnUploadApi(makeEnv());
424
+ await pushSig(api);
425
+ g.http = httpFor(FILE, ARCHIVE);
426
+ await api.upload.post();
427
+ expect(last?.status).toBe(200);
428
+ await fsp.rm(join(cdnDir, FILE));
429
+ await fsp.rm(join(cdnDir, SIG_FILE));
430
+ expect(await fsp.readdir(cdnDir)).toEqual([]);
431
+ // 先补 sig(pair 前置),再补 tgz
432
+ g.http = httpFor(SIG_FILE, SIG_BYTES);
433
+ await api.upload.post();
434
+ expect(last?.status).toBe(200);
435
+ g.http = httpFor(FILE, ARCHIVE);
436
+ await api.upload.post();
437
+ expect(last?.status).toBe(200);
438
+ expect(createHash("sha256").update(await fsp.readFile(join(cdnDir, FILE))).digest("hex")).toBe(SHA256);
439
+ expect(JSON.parse(await fsp.readFile(join(cdnDir, SIG_FILE), "utf8"))).toMatchObject({ alg: "Ed25519" });
440
+ });
441
+
442
+ it("异字节同版本 → 409 不碰盘(防「文件先被换、DB 后拒」的字节持久损坏)", async () => {
443
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
444
+ const api = createCdnUploadApi(makeEnv());
445
+ await pushSig(api);
446
+ g.http = httpFor(FILE, ARCHIVE);
447
+ await api.upload.post();
448
+ expect(last?.status).toBe(200);
449
+ const tampered = new TextEncoder().encode("tampered-bytes\n");
450
+ g.http = httpFor(FILE, tampered, uploadSignature(FILE, tampered));
451
+ await api.upload.post();
452
+ expect(last?.status).toBe(409);
453
+ // 盘上仍是原字节
454
+ expect(createHash("sha256").update(await fsp.readFile(join(cdnDir, FILE))).digest("hex")).toBe(SHA256);
455
+ });
456
+ });
457
+
458
+ describe("POST upload(pair 验签:旁车必须与实收字节密码学绑定,全分支终审 I2)", () => {
459
+ it("给定 竞争受信 key 预推「形全实毒」旁车(对自制字节签名,sha 与实收 tgz 不符),当 受害方推 tgz,则 409 拒配对、不写行、tgz 不落盘", async () => {
460
+ const rival = generateKeyPair();
461
+ await trustKey("rival.publisher", rival.publicKeyHex);
462
+ // 毒旁车:rival 对自己字节按 demo-solution@0.1.0 规范串签名——五字段全合法,唯 sha256 与受害方字节无关
463
+ const poisoned = signedArchiveFor(new TextEncoder().encode("poison-bytes"), "demo-solution", "0.1.0", { keyPair: rival, keyId: "rival.publisher" });
464
+ const poisonBytes = new TextEncoder().encode(JSON.stringify(poisoned.sig));
465
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
466
+ const api = createCdnUploadApi(makeEnv());
467
+ await pushSigAs(api, "rival.publisher", rival, poisonBytes);
468
+ expect(last?.status).toBe(200); // 旁车先行本身放行——验签推迟到 pair 时与实收字节绑定
469
+ g.http = httpFor(FILE, ARCHIVE);
470
+ await api.upload.post();
471
+ expect(last?.status).toBe(409);
472
+ expect(String(last?.msg)).toContain("验签");
473
+ expect(dbo.prepare("SELECT COUNT(*) c FROM market_release").all()[0].c).toBe(0);
474
+ expect(await fsp.readdir(cdnDir)).not.toContain(FILE);
475
+ });
476
+
477
+ it("给定 旁车由另一把受信 key 签出(字节绑定正确,但 key_id 与推送身份不符),当 推送 tgz,则 409 拒(一行一锚:行 key_id 即验签 key_id)", async () => {
478
+ const other = generateKeyPair();
479
+ await trustKey("other.publisher", other.publicKeyHex);
480
+ const foreign = signedArchiveFor(ARCHIVE, "demo-solution", "0.1.0", { keyPair: other, keyId: "other.publisher" });
481
+ const foreignBytes = new TextEncoder().encode(JSON.stringify(foreign.sig));
482
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
483
+ const api = createCdnUploadApi(makeEnv());
484
+ await pushSigAs(api, "other.publisher", other, foreignBytes);
485
+ expect(last?.status).toBe(200);
486
+ g.http = httpFor(FILE, ARCHIVE); // TEST_KEY 身份推 tgz
487
+ await api.upload.post();
488
+ expect(last?.status).toBe(409);
489
+ expect(String(last?.msg)).toContain("key");
490
+ expect(dbo.prepare("SELECT COUNT(*) c FROM market_release").all()[0].c).toBe(0);
491
+ expect(await fsp.readdir(cdnDir)).not.toContain(FILE);
492
+ });
493
+
494
+ it("给定 旁车 registry_id 与实收绑定正确,当 正常 pair,则 200(验签不误伤合法发布:canonical 首段 = 旁车自带签发方身份,非本站点 registry)", async () => {
495
+ // 防实现错把站点 registryId 塞进包级 canonical——包级 canonical 首段是签发方身份(跨站签发合法)
496
+ const foreignRegistry = signedArchiveFor(ARCHIVE, "demo-solution", "0.1.0", { keyPair: TEST_KEY, registryId: "upstream.registry" });
497
+ const sigBytes = new TextEncoder().encode(JSON.stringify(foreignRegistry.sig));
498
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
499
+ const api = createCdnUploadApi(makeEnv());
500
+ await pushSig(api, { sigBytes });
501
+ expect(last?.status).toBe(200);
502
+ g.http = httpFor(FILE, ARCHIVE);
503
+ await api.upload.post();
504
+ expect(last?.status).toBe(200);
505
+ expect(dbo.prepare("SELECT COUNT(*) c FROM market_release").all()[0].c).toBe(1);
506
+ });
507
+ });
508
+
509
+ describe("POST upload(manifest 抽取 + 入库钳制)", () => {
510
+ it("体积闸:tar 清单单条目超大(解压炸弹)→ 413 零落盘", async () => {
511
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
512
+ const bombListing = "-rw-r--r-- 0/0 300000000 2026-01-01 00:00 big.bin\n";
513
+ const api = createCdnUploadApi(makeEnv({ exec: uploadExec({ listing: bombListing }) }));
514
+ await pushSig(api);
515
+ g.http = httpFor(FILE, ARCHIVE);
516
+ await api.upload.post();
517
+ expect(last?.status).toBe(413);
518
+ // 零落盘 = tgz 不落(sig 旁车是前置合法推送)+ 库无行
519
+ expect(await fsp.readdir(cdnDir)).not.toContain(FILE);
520
+ expect(dbo.prepare("SELECT COUNT(*) c FROM market_release").all()[0].c).toBe(0);
521
+ });
522
+
523
+ it("manifest.name/version 与上传文件名不符 → 400 零落盘", async () => {
524
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
525
+ const api = createCdnUploadApi(makeEnv({ exec: uploadExec({ manifest: { ...MANIFEST, version: "9.9.9" } }) }));
526
+ await pushSig(api);
527
+ g.http = httpFor(FILE, ARCHIVE);
528
+ await api.upload.post();
529
+ expect(last?.status).toBe(400);
530
+ expect(String(last?.msg)).toContain("9.9.9");
531
+ expect(await fsp.readdir(cdnDir)).not.toContain(FILE);
532
+ expect(dbo.prepare("SELECT COUNT(*) c FROM market_release").all()[0].c).toBe(0);
533
+ });
534
+
535
+ it("坏 manifest(解析失败)→ 400 零落盘", async () => {
536
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
537
+ const badExec: ExecFace = {
538
+ run: async (argv) => {
539
+ if (argv[0] === "tar" && argv[1] === "-tvzf")
540
+ return { code: 0, stdout: "-rw-r--r-- 0/0 200 2026-01-01 00:00 manifest.yaml\n", stderr: "" };
541
+ if (argv[0] === "tar" && argv[1] === "-xzf") {
542
+ await fsp.mkdir(argv[4]!, { recursive: true });
543
+ await fsp.writeFile(join(argv[4]!, "manifest.yaml"), "{not-json");
544
+ return { code: 0, stdout: "", stderr: "" };
545
+ }
546
+ return { code: 0, stdout: "", stderr: "" };
547
+ },
548
+ };
549
+ const api = createCdnUploadApi(makeEnv({ exec: badExec }));
550
+ await pushSig(api);
551
+ g.http = httpFor(FILE, ARCHIVE);
552
+ await api.upload.post();
553
+ expect(last?.status).toBe(400);
554
+ expect(await fsp.readdir(cdnDir)).not.toContain(FILE);
555
+ });
556
+
557
+ it("入库钳制:summary>200/description>2000 截断入库(catalog 展示字段)", async () => {
558
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
559
+ const fat = { ...MANIFEST, summary: "s".repeat(300), description: "d".repeat(3000) };
560
+ const api = createCdnUploadApi(makeEnv({ exec: uploadExec({ manifest: fat }) }));
561
+ await pushSig(api);
562
+ g.http = httpFor(FILE, ARCHIVE);
563
+ await api.upload.post();
564
+ expect(last?.status).toBe(200);
565
+ const row = dbo.prepare("SELECT manifest FROM market_release WHERE product=? AND version=?").get("demo-solution", "0.1.0");
566
+ const stored = JSON.parse(row.manifest);
567
+ expect(stored.summary).toHaveLength(200);
568
+ expect(stored.description).toHaveLength(2000);
569
+ });
570
+ });
571
+
572
+ describe("POST upload(旁车幂等重推留痕,minor M3)", () => {
573
+ it("给定 已配对发布,当 同字节 .sig 重推,则 200 幂等且 market_history 补一条 publish/success(message 含「幂等」)", async () => {
574
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
575
+ const api = createCdnUploadApi(makeEnv());
576
+ await pushSig(api);
577
+ g.http = httpFor(FILE, ARCHIVE);
578
+ await api.upload.post();
579
+ expect(last?.status).toBe(200);
580
+ const before = dbo.prepare("SELECT COUNT(*) c FROM market_history WHERE action='publish' AND status='success'").all()[0].c;
581
+ // 同字节重推旁车 → 幂等 200 + 留痕
582
+ g.http = httpFor(SIG_FILE, SIG_BYTES);
583
+ await api.upload.post();
584
+ expect(last?.status).toBe(200);
585
+ const after = dbo.prepare("SELECT COUNT(*) c FROM market_history WHERE action='publish' AND status='success'").all()[0].c;
586
+ expect(after).toBe(before + 1);
587
+ const row = dbo.prepare("SELECT * FROM market_history WHERE action='publish' AND status='success' ORDER BY rowid DESC LIMIT 1").get();
588
+ expect(row).toMatchObject({ solution: "demo-solution", version: "0.1.0", operator: KEY_ID });
589
+ expect(String(row.message)).toContain("幂等");
590
+ });
591
+ });
592
+
593
+ describe("POST upload(并发与错误面收口,minors M1/M4)", () => {
594
+ it("数据表 所有权竞态:预检后他方并发插入同 product 异 key 行 → 写事务内复核 409(首发绑定双绑防线)", async () => {
595
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
596
+ const api = createCdnUploadApi(makeEnv());
597
+ await pushSig(api);
598
+ // 模拟竞态:db.tx 被调用时(预检已通过)先提交他方首发行
599
+ const origTx = g.db.tx.bind(g.db);
600
+ let injected = false;
601
+ g.db.tx = async (fn: unknown) => {
602
+ if (!injected) {
603
+ injected = true;
604
+ dbo.prepare("INSERT INTO market_release (product,version,key_id,sha256,size,published_at,published_at_source,yanked,first_seen_at) VALUES (?,?,?,?,?,?,?,?,?)")
605
+ .run("demo-solution", "0.0.1", "other.publisher", "deadbeef", 1, 1000, "upload", 0, 1000);
606
+ }
607
+ return origTx(fn as never);
608
+ };
609
+ g.http = httpFor(FILE, ARCHIVE);
610
+ await api.upload.post();
611
+ g.db.tx = origTx;
612
+ expect(last?.status).toBe(409);
613
+ expect(String(last?.msg)).toContain("所有权");
614
+ // 本方 tgz 字节已回收(他方 0.0.1 行不受波及)
615
+ expect(await fsp.readdir(cdnDir)).not.toContain(FILE);
616
+ expect(dbo.prepare("SELECT COUNT(*) c FROM market_release WHERE version='0.1.0'").all()[0].c).toBe(0);
617
+ });
618
+
619
+ it("数据表 db.tx 抛底层 SQL 错误 → 500 信封固定人话(不含 SQL 文本),审计 message 保留细节", async () => {
620
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
621
+ const api = createCdnUploadApi(makeEnv());
622
+ await pushSig(api);
623
+ const origTx = g.db.tx.bind(g.db);
624
+ g.db.tx = async () => {
625
+ throw new Error("SQLITE_ERROR: no such table: market_release");
626
+ };
627
+ g.http = httpFor(FILE, ARCHIVE);
628
+ await api.upload.post();
629
+ g.db.tx = origTx;
630
+ expect(last?.status).toBe(500);
631
+ expect(String(last?.msg)).not.toContain("SQLITE");
632
+ expect(String(last?.msg)).toContain("写库");
633
+ // 审计保留细节(观测面内部)
634
+ const row = dbo.prepare("SELECT * FROM market_history WHERE action='publish' AND status='failed' ORDER BY rowid DESC LIMIT 1").get();
635
+ expect(String(row.message)).toContain("SQLITE");
636
+ // 本方字节回收
637
+ expect(await fsp.readdir(cdnDir)).not.toContain(FILE);
638
+ });
639
+ });
640
+
641
+ describe("POST upload(发布审计落 market_history)", () => {
642
+ it("发布成功 → action=publish status=success(operator=key_id);409 冲突也记 failed", async () => {
643
+ const { createCdnUploadApi } = await import("../../src/oj-market/api/cdn/api");
644
+ const api = createCdnUploadApi(makeEnv());
645
+ await pushSig(api);
646
+ g.http = httpFor(FILE, ARCHIVE);
647
+ await api.upload.post();
648
+ expect(last?.status).toBe(200);
649
+ const ok = dbo.prepare("SELECT * FROM market_history WHERE action='publish' AND status='success'").all();
650
+ expect(ok).toHaveLength(1);
651
+ expect(ok[0]).toMatchObject({ solution: "demo-solution", version: "0.1.0", operator: KEY_ID });
652
+ // 异字节冲突 → failed 留痕
653
+ const tampered = new TextEncoder().encode("tampered-bytes\n");
654
+ g.http = httpFor(FILE, tampered, uploadSignature(FILE, tampered));
655
+ await api.upload.post();
656
+ expect(last?.status).toBe(409);
657
+ const failed = dbo.prepare("SELECT * FROM market_history WHERE action='publish' AND status='failed'").all();
658
+ expect(failed.length).toBe(1);
659
+ expect(failed[0]).toMatchObject({ solution: "demo-solution", version: "0.1.0", operator: KEY_ID });
660
+ });
661
+ });