@oj-module/cli 0.2.30 → 0.2.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/manual.md +5 -5
- package/devkit/module-dev-and-cdn-playground.md +1 -1
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +23 -14
- package/src/__tests__/list.test.ts +176 -0
- package/src/__tests__/registry-cli.test.ts +522 -0
- package/src/__tests__/registry-export.test.ts +319 -0
- package/src/__tests__/registry-import.test.ts +336 -0
- package/src/__tests__/registry-sign.test.ts +352 -0
- package/src/cli-output.ts +81 -0
- package/src/cli.ts +298 -39
- package/src/config-cmd.ts +100 -0
- package/src/contract/__tests__/handler-scan.test.ts +74 -2
- package/src/contract/handler-scan.ts +27 -2
- package/src/enable.ts +1 -1
- package/src/exit-codes.ts +33 -0
- package/src/index.ts +5 -9
- package/src/install.ts +120 -38
- package/src/lifecycle/__tests__/authoring.test.ts +6 -5
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
- package/src/lifecycle/__tests__/helpers.ts +10 -6
- package/src/lifecycle/__tests__/install.test.ts +72 -15
- package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
- package/src/lifecycle/__tests__/publish.test.ts +174 -33
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
- package/src/lifecycle/__tests__/sign.test.ts +9 -8
- package/src/lifecycle/__tests__/smoke.test.ts +1 -1
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/lifecycle/sign.ts +8 -7
- package/src/list.ts +146 -0
- package/src/manifest-contract.ts +11 -5
- package/src/market/__tests__/bootstrap.test.ts +4 -1
- package/src/market/__tests__/build-index.test.ts +529 -42
- package/src/market/__tests__/fixture.test.ts +17 -1
- package/src/market/__tests__/publish.test.ts +8 -7
- package/src/market/__tests__/setup.test.ts +4 -4
- package/src/market/__tests__/sharding.test.ts +90 -0
- package/src/market/__tests__/verify.test.ts +205 -12
- package/src/market/__tests__/yank-ledger.test.ts +60 -0
- package/src/market/bootstrap.ts +8 -3
- package/src/market/build-index.ts +407 -40
- package/src/market/cdn-layout.ts +3 -2
- package/src/market/command.ts +40 -6
- package/src/market/failure-playbook.ts +1 -1
- package/src/market/fixture.ts +20 -3
- package/src/market/index-builder.ts +120 -47
- package/src/market/publish.ts +3 -1
- package/src/market/setup.ts +3 -3
- package/src/market/sharding.ts +94 -0
- package/src/market/trial.ts +1 -1
- package/src/market/verify.ts +213 -50
- package/src/market/walkthrough.ts +7 -6
- package/src/market/yank-ledger.ts +91 -0
- package/src/publish.ts +250 -38
- package/src/registry-export.ts +405 -0
- package/src/registry-import.ts +410 -0
- package/src/registry-sidecar.ts +84 -0
- package/src/registry-sign.ts +322 -0
- package/src/registry-source.ts +192 -0
- package/src/sign.ts +13 -10
- package/src/site.ts +4 -4
- package/src/trust.ts +1 -1
- package/src/uninstall.ts +14 -2
- package/src/usage.ts +130 -25
- package/src/view.ts +94 -0
- package/templates/cdn/README.md +35 -5
- package/templates/config.yaml +10 -0
- package/templates/init.include.yaml +1 -1
- package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
- package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
- package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
- package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
- package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
- package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
- package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
- package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
- package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
- package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
- package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
- package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
- package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
- package/templates/src/_platform/api/manifest.yaml +1 -0
- package/templates/src/_platform/api/market/api.ts +362 -99
- package/templates/src/_platform/api/market/index-source.ts +112 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
- package/templates/src/_platform/api/schema.yaml +8 -0
- package/templates/src/_platform/api/solutions/api.ts +8 -3
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
- package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
- package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
- package/templates/src/oj-market/web/locales/en-US.json +1 -0
- package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
- package/templates/src/oj-market/web/types.ts +3 -1
- package/templates/tests/lifecycle/installer.spec.ts +24 -0
- package/templates/tests/lifecycle/market-api.spec.ts +641 -85
- package/templates/tests/lifecycle/preflight.spec.ts +52 -0
- package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
- package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
- package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
- package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
- package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
- package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
- package/templates/tests/rbac-solution-perms.spec.ts +41 -0
- package/vendor/host-versions.json +3 -3
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// POST /api/_platform/market/install superadmin——runInstall 编排(端点不重复编排,P1-10 状态机负责推进)
|
|
4
4
|
// GET /api/_platform/market/task?id= superadmin——任务轮询消费面(§10.2;内容写侧已脱敏,读侧无秘密可泄)
|
|
5
5
|
// GET /api/_platform/market/meta 登录即可——{apiVersion}(peerPlatform 预检)
|
|
6
|
-
// GET /api/_platform/market/index superadmin——CDN
|
|
6
|
+
// GET /api/_platform/market/index superadmin——CDN 三级索引(catalog.yaml + shards/<n>.yaml,ETag/If-None-Match 条件请求)+ 本地 state 对比
|
|
7
7
|
// 简报 `task/:id` 段参数形态在模板 http 面无先例(contacts/detail?id= 同款 query 惯例),备案于任务报告。
|
|
8
8
|
// 步骤面全 stub 注入(DIP):fetch/exec/reload 不真跑;register/smoke/audit 走真实内存库夹具。
|
|
9
9
|
/* eslint-disable test/prefer-lowercase-title -- 端点动词/路径是验收锚点,保留大写(installer.spec 同约定) */
|
|
@@ -23,11 +23,13 @@ import { parse } from "yaml";
|
|
|
23
23
|
import { defaultTestKey, nodeVerifyEd25519, signedArchiveFor } from "../../../src/lifecycle/__tests__/helpers";
|
|
24
24
|
import { signIndex } from "../../../src/publish";
|
|
25
25
|
import { readLock, writeLock } from "../../src/_platform/api/_lifecycle/lock";
|
|
26
|
+
import { compareVersions } from "../../src/_platform/api/_lifecycle/semver";
|
|
26
27
|
import { createStateStore } from "../../src/_platform/api/_lifecycle/state";
|
|
27
28
|
import { getTask, writeTask } from "../../src/_platform/api/_lifecycle/tasks";
|
|
28
29
|
import { createTrustStore } from "../../src/_platform/api/_lifecycle/trust";
|
|
29
30
|
import { invalidateAllUsers } from "../../src/_platform/api/_shared/service/infra/access";
|
|
30
31
|
import { createMarketApi, marketEnv, parseVersionsJson, versionsJsonPathOf } from "../../src/_platform/api/market/api";
|
|
32
|
+
import { localIndexSource } from "../../src/_platform/api/market/index-source";
|
|
31
33
|
import { installDb } from "../fixture";
|
|
32
34
|
|
|
33
35
|
function hexToBytes(hex: string): Uint8Array {
|
|
@@ -61,6 +63,7 @@ function manifestFixture(version = "0.1.0"): any {
|
|
|
61
63
|
version,
|
|
62
64
|
summary: "演示方案",
|
|
63
65
|
author: "oj-module",
|
|
66
|
+
license: "MIT",
|
|
64
67
|
changelog: [`${version} 变更条目`],
|
|
65
68
|
peerRuntime: ">=0.1.0",
|
|
66
69
|
archive: { file: `demo-solution-${version}.tgz`, sha256: SHA256, size: ARCHIVE.byteLength, signature: signedArchiveFor(ARCHIVE, "demo-solution", version, { keyPair: TEST_KEY }).sig },
|
|
@@ -82,24 +85,42 @@ function manifestFixture(version = "0.1.0"): any {
|
|
|
82
85
|
};
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
/**
|
|
86
|
-
const
|
|
88
|
+
/** REG-CS2 时效:签发时刻与失效时刻(epoch 秒;用例里取「刚签发」以免被判过期) */
|
|
89
|
+
const ISSUED_AT = Math.floor(Date.now() / 1000);
|
|
90
|
+
const EXPIRES_AT = ISSUED_AT + 7 * 24 * 60 * 60;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Form B 三级索引夹具(REG-CS5:catalog + shards)。JSON 序列充当 yaml.parse 全局的载荷——
|
|
94
|
+
* 解析面是 oj 全局,端点只管喂文本取对象。catalog 只记「包名 → 分片号」无版本明细。
|
|
95
|
+
*/
|
|
96
|
+
const CATALOG = JSON.stringify({
|
|
97
|
+
// REG-CS1/CS2:catalog = 代次与时效真源,所有索引签名必须与它同代
|
|
98
|
+
seq: 1,
|
|
99
|
+
issued_at: ISSUED_AT,
|
|
100
|
+
expires_at: EXPIRES_AT,
|
|
87
101
|
generated_at: "2026-10-17T00:00:00Z",
|
|
88
102
|
cdn: CDN,
|
|
103
|
+
// 片号 → 代次钉值(REG-CS1③:分片签名 seq 必须等于钉值;byte 相同的分片可跨代复用)
|
|
104
|
+
shards: { 1: 1 },
|
|
105
|
+
// 包名 → 分片号(无版本明细;版本明细在分片里)
|
|
89
106
|
solutions: [
|
|
90
|
-
{
|
|
91
|
-
|
|
92
|
-
latest: "0.2.0",
|
|
93
|
-
versions: [
|
|
94
|
-
{ version: "0.2.0", file: "demo-solution-0.2.0.tgz", sha256: SHA256, size: ARCHIVE.byteLength },
|
|
95
|
-
{ version: "0.1.0", file: "demo-solution-0.1.0.tgz", sha256: SHA256, size: ARCHIVE.byteLength },
|
|
96
|
-
],
|
|
97
|
-
},
|
|
98
|
-
{ name: "wiki", latest: "1.3.0", versions: [{ version: "1.3.0", file: "wiki-1.3.0.tgz", sha256: "x", size: 1 }] },
|
|
107
|
+
{ name: "demo-solution", shards: [1] },
|
|
108
|
+
{ name: "wiki", shards: [1] },
|
|
99
109
|
],
|
|
100
110
|
});
|
|
101
|
-
/** manifests.yaml
|
|
102
|
-
const
|
|
111
|
+
/** 分片正文(纯内容无 meta:版本条目即 Form B manifest——原 manifests.yaml 聚合清单并入分片) */
|
|
112
|
+
const SHARD_1 = JSON.stringify({
|
|
113
|
+
solutions: [
|
|
114
|
+
{ name: "demo-solution", latest: "0.2.0", versions: [manifestFixture("0.2.0"), manifestFixture("0.1.0")] },
|
|
115
|
+
{ name: "wiki", latest: "1.3.0", versions: [{ name: "wiki", version: "1.3.0", archive: { file: "wiki-1.3.0.tgz", sha256: "x", size: 1 } }] },
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** 基线代次 */
|
|
120
|
+
const SEQ = 1;
|
|
121
|
+
/** 索引签名(REG-CS1:签名覆盖 (kind, seq)——换 kind 或换代都验不过) */
|
|
122
|
+
const CATALOG_SIG = JSON.stringify(signIndex("catalog", SEQ, CATALOG, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
123
|
+
const SHARD_1_SIG = JSON.stringify(signIndex("shard", 1, SHARD_1, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
103
124
|
|
|
104
125
|
let root: string;
|
|
105
126
|
let sharedRoot: string;
|
|
@@ -119,10 +140,16 @@ const fetchInits: unknown[] = [];
|
|
|
119
140
|
const cdnFetch = ((url: string, init?: unknown) => {
|
|
120
141
|
fetchCalls.push(url);
|
|
121
142
|
fetchInits.push(init);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
143
|
+
// 基线 CDN 已签名(REG-CS1:缺签名即拒,故所有「正常路径」夹具都必须带 .sig)。
|
|
144
|
+
// .sig 判定先行:`catalog.yaml.sig` 也以 `.yaml` 结尾,顺序反了会把签名当正文。
|
|
145
|
+
if (url.endsWith("catalog.yaml.sig"))
|
|
146
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(CATALOG_SIG).buffer as ArrayBuffer };
|
|
147
|
+
if (url.endsWith("shards/1.yaml.sig"))
|
|
148
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(SHARD_1_SIG).buffer as ArrayBuffer };
|
|
149
|
+
const text = url.endsWith("catalog.yaml")
|
|
150
|
+
? CATALOG
|
|
151
|
+
: url.endsWith("shards/1.yaml")
|
|
152
|
+
? SHARD_1
|
|
126
153
|
: null;
|
|
127
154
|
if (text !== null) {
|
|
128
155
|
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
@@ -194,6 +221,10 @@ function makeEnv(overrides: Partial<MarketEnv> = {}): MarketEnv {
|
|
|
194
221
|
modulesJsonPath,
|
|
195
222
|
apiPrefix: "/api/demo-solution",
|
|
196
223
|
configPath: join(root, "config.yaml"),
|
|
224
|
+
// REG-CS6:安装全程需 canonical origin(相对基址补全 + 冒烟探针 URL 恒相对)。生产由
|
|
225
|
+
// config.yaml vars.canonical_origin 供给;此处给固定值让既有安装用例不受缺失影响,
|
|
226
|
+
// 缺失/非法的专门用例显式覆盖为 undefined。
|
|
227
|
+
canonicalOrigin: "http://127.0.0.1:9778",
|
|
197
228
|
cdnBase: async () => CDN,
|
|
198
229
|
fetch: cdnFetch,
|
|
199
230
|
exec: bundleExec(),
|
|
@@ -284,19 +315,20 @@ describe("权限门(R9:市场编排 superadmin 独占,meta 登录即可)
|
|
|
284
315
|
});
|
|
285
316
|
|
|
286
317
|
describe("POST install(cdn:Form B 索引解析属市场侧,安装器不拼 URL)", () => {
|
|
287
|
-
it("指定版本:解析
|
|
318
|
+
it("指定版本:解析 catalog+shard → runInstall 推进至 done,task 文件全 stage 就位且索引拉取不带 no-cache", async () => {
|
|
288
319
|
const api = createMarketApi(makeEnv());
|
|
289
320
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
290
321
|
await api.install.post();
|
|
291
322
|
expect(last?.status).toBe(200);
|
|
292
323
|
const taskId = (last?.data as { task_id?: unknown })?.task_id;
|
|
293
324
|
expect(typeof taskId).toBe("string");
|
|
294
|
-
// 索引解析:
|
|
295
|
-
expect(fetchCalls[0]).toBe(`${CDN}
|
|
296
|
-
expect(fetchCalls).toContain(`${CDN}
|
|
325
|
+
// 索引解析:catalog.yaml + shards/1.yaml + 分发包,URL 由 base 归一化拼装
|
|
326
|
+
expect(fetchCalls[0]).toBe(`${CDN}catalog.yaml`);
|
|
327
|
+
expect(fetchCalls).toContain(`${CDN}shards/1.yaml`);
|
|
297
328
|
expect(fetchCalls).toContain(`${CDN}demo-solution-0.1.0.tgz`);
|
|
298
|
-
//
|
|
299
|
-
|
|
329
|
+
// REG-CS5④:读面不再发 cache-control: no-cache——新鲜度由服务端 max-age=60,
|
|
330
|
+
// stale-while-revalidate=60 + ETag/If-None-Match 条件请求承载(Q8)
|
|
331
|
+
expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["cache-control"] === undefined)).toBe(true);
|
|
300
332
|
// 后台推进:runInstall 状态机逐 stage 落盘,终态 done + 全 stage 明细
|
|
301
333
|
const rec = await getTask(taskDir, taskId as string);
|
|
302
334
|
expect(rec?.status).toBe("done");
|
|
@@ -331,33 +363,132 @@ describe("POST install(cdn:Form B 索引解析属市场侧,安装器不拼
|
|
|
331
363
|
});
|
|
332
364
|
});
|
|
333
365
|
|
|
334
|
-
describe("
|
|
366
|
+
describe("REG-CS4 yank(安装侧精确版本拒装)", () => {
|
|
367
|
+
/** 带 yanked 标记的三级索引:0.2.0 被下线、0.1.0 正常;latest 已被源方改为 0.1.0 */
|
|
368
|
+
function yankedCdnFetch(): FetchFace {
|
|
369
|
+
const catalog = JSON.stringify({
|
|
370
|
+
seq: 1,
|
|
371
|
+
issued_at: ISSUED_AT,
|
|
372
|
+
expires_at: EXPIRES_AT,
|
|
373
|
+
shards: { 1: 1 },
|
|
374
|
+
solutions: [{ name: "demo-solution", shards: [1] }],
|
|
375
|
+
});
|
|
376
|
+
const shard = JSON.stringify({
|
|
377
|
+
solutions: [{
|
|
378
|
+
name: "demo-solution",
|
|
379
|
+
latest: "0.1.0",
|
|
380
|
+
versions: [
|
|
381
|
+
{ ...manifestFixture("0.2.0"), yanked: true, yanked_at: ISSUED_AT },
|
|
382
|
+
manifestFixture("0.1.0"),
|
|
383
|
+
],
|
|
384
|
+
}],
|
|
385
|
+
});
|
|
386
|
+
const catalogSig = JSON.stringify(signIndex("catalog", SEQ, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
387
|
+
const shardSig = JSON.stringify(signIndex("shard", 1, shard, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
388
|
+
return ((url: string) => {
|
|
389
|
+
const text = String(url).endsWith("catalog.yaml")
|
|
390
|
+
? catalog
|
|
391
|
+
: String(url).endsWith("shards/1.yaml") ? shard : null;
|
|
392
|
+
if (text !== null)
|
|
393
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
394
|
+
if (String(url).endsWith(".yaml.sig")) {
|
|
395
|
+
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
396
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
397
|
+
}
|
|
398
|
+
if (String(url).endsWith(".tgz"))
|
|
399
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
400
|
+
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
401
|
+
}) as FetchFace;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
it("精确安装被下线版本 → 409 拒装(含「下线」人话),不触碰 .tgz", async () => {
|
|
405
|
+
const api = createMarketApi(makeEnv({ fetch: yankedCdnFetch() }));
|
|
406
|
+
fetchCalls.length = 0;
|
|
407
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.2.0", source: "cdn" } };
|
|
408
|
+
await api.install.post();
|
|
409
|
+
expect(last?.status).toBe(409);
|
|
410
|
+
expect(String(last?.msg)).toContain("下线");
|
|
411
|
+
expect(fetchCalls.some(u => u.endsWith(".tgz"))).toBe(false);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("latest 路径不命中 yanked(源方已把 latest 改为未下线版本)→ 正常装 0.1.0", async () => {
|
|
415
|
+
const api = createMarketApi(makeEnv({ fetch: yankedCdnFetch() }));
|
|
416
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
|
|
417
|
+
await api.install.post();
|
|
418
|
+
expect(last?.status).toBe(200);
|
|
419
|
+
const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
|
|
420
|
+
expect((await getTask(taskDir, taskId))?.version).toBe("0.1.0");
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe("相对基址(REG-CS6:/cdn 自供按配置的 canonical_origin 补全,绝不读请求 Host 头防 SSRF)", () => {
|
|
335
425
|
const REL = "/cdn";
|
|
336
|
-
const
|
|
337
|
-
const relEnv = () => makeEnv({ cdnBase: async () => REL });
|
|
426
|
+
const FORGED = "169.254.169.254"; // 伪造 Host(云元数据 IP)——绝不可牵引出站
|
|
427
|
+
const relEnv = (overrides: Partial<MarketEnv> = {}) => makeEnv({ cdnBase: async () => REL, ...overrides });
|
|
338
428
|
|
|
339
|
-
it("
|
|
340
|
-
const api = createMarketApi(relEnv());
|
|
341
|
-
|
|
429
|
+
it("自供回归:canonical_origin 配置 → 相对 /cdn URL 按配置 origin 补全绝对 URL(非 Host 头)", async () => {
|
|
430
|
+
const api = createMarketApi(relEnv({ canonicalOrigin: "http://127.0.0.1:5174" }));
|
|
431
|
+
fetchCalls.length = 0;
|
|
432
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" }, headers: { host: FORGED } };
|
|
433
|
+
await api.install.post();
|
|
434
|
+
expect(last?.status).toBe(200);
|
|
435
|
+
// 按配置的 canonical origin 补全,而非伪造 Host 头
|
|
436
|
+
expect(fetchCalls).toContain("http://127.0.0.1:5174/cdn/demo-solution-0.1.0.tgz");
|
|
437
|
+
expect(fetchCalls.some(u => String(u).includes(FORGED))).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("index:canonical_origin 配置 → 索引与分片拉取按配置 origin 补全(非 Host 头)", async () => {
|
|
441
|
+
const api = createMarketApi(relEnv({ canonicalOrigin: "http://127.0.0.1:5174" }));
|
|
442
|
+
fetchCalls.length = 0;
|
|
443
|
+
g.http = { user: { id: 1 }, headers: { host: FORGED } };
|
|
342
444
|
await api.index.get();
|
|
343
445
|
expect(last?.status).toBe(200);
|
|
344
|
-
expect(fetchCalls).toContain(
|
|
345
|
-
expect(fetchCalls).toContain(
|
|
446
|
+
expect(fetchCalls).toContain("http://127.0.0.1:5174/cdn/catalog.yaml");
|
|
447
|
+
expect(fetchCalls).toContain("http://127.0.0.1:5174/cdn/shards/1.yaml");
|
|
448
|
+
expect(fetchCalls.some(u => String(u).includes(FORGED))).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("index:canonical_origin 缺失 + 相对基址 → 明确报错指向 vars.canonical_origin(不回落 Host)", async () => {
|
|
452
|
+
const api = createMarketApi(relEnv({ canonicalOrigin: undefined }));
|
|
453
|
+
fetchCalls.length = 0;
|
|
454
|
+
g.http = { user: { id: 1 }, headers: { host: FORGED } };
|
|
455
|
+
await api.index.get();
|
|
456
|
+
expect(last?.status).toBe(503);
|
|
457
|
+
expect(last?.msg).toContain("vars.canonical_origin");
|
|
458
|
+
// 未发起任何出站;伪造 Host 头绝不被采用
|
|
459
|
+
expect(fetchCalls.length).toBe(0);
|
|
460
|
+
expect(fetchCalls.some(u => String(u).includes(FORGED))).toBe(false);
|
|
346
461
|
});
|
|
347
462
|
|
|
348
|
-
it("install:
|
|
349
|
-
const api = createMarketApi(relEnv());
|
|
350
|
-
|
|
463
|
+
it("install:canonical_origin 非法(ftp://)→ 明确报错指向 vars.canonical_origin", async () => {
|
|
464
|
+
const api = createMarketApi(relEnv({ canonicalOrigin: "ftp://nope" }));
|
|
465
|
+
fetchCalls.length = 0;
|
|
466
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" }, headers: { host: FORGED } };
|
|
351
467
|
await api.install.post();
|
|
352
|
-
expect(last?.status).toBe(
|
|
353
|
-
expect(
|
|
468
|
+
expect(last?.status).toBe(503);
|
|
469
|
+
expect(last?.msg).toContain("vars.canonical_origin");
|
|
470
|
+
expect(fetchCalls.length).toBe(0);
|
|
354
471
|
});
|
|
355
472
|
|
|
356
|
-
it("
|
|
357
|
-
|
|
358
|
-
|
|
473
|
+
it("install:canonical_origin 缺失(冒烟探针 URL 恒相对)→ 明确报错指向 vars.canonical_origin", async () => {
|
|
474
|
+
// 绝对基址(外部 CDN)也要 canonical origin——冒烟探针 URL 恒为站点相对路径
|
|
475
|
+
const api = createMarketApi(makeEnv({ canonicalOrigin: undefined }));
|
|
476
|
+
fetchCalls.length = 0;
|
|
477
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" }, headers: { host: FORGED } };
|
|
478
|
+
await api.install.post();
|
|
479
|
+
expect(last?.status).toBe(503);
|
|
480
|
+
expect(last?.msg).toContain("vars.canonical_origin");
|
|
481
|
+
expect(fetchCalls.length).toBe(0);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("绝对基址(外部 CDN)index + canonical_origin 缺失 → 无需 origin,索引照常", async () => {
|
|
485
|
+
const api = createMarketApi(makeEnv({ canonicalOrigin: undefined }));
|
|
486
|
+
fetchCalls.length = 0;
|
|
487
|
+
g.http = { user: { id: 1 }, headers: { host: FORGED } };
|
|
359
488
|
await api.index.get();
|
|
360
|
-
expect(
|
|
489
|
+
expect(last?.status).toBe(200);
|
|
490
|
+
expect(fetchCalls).toContain(`${CDN}catalog.yaml`);
|
|
491
|
+
expect(fetchCalls.some(u => String(u).includes(FORGED))).toBe(false);
|
|
361
492
|
});
|
|
362
493
|
});
|
|
363
494
|
|
|
@@ -680,7 +811,7 @@ describe("POST enable(生命周期开关:P1-13 瘦客户端触发面,四
|
|
|
680
811
|
});
|
|
681
812
|
});
|
|
682
813
|
|
|
683
|
-
describe("GET index(CDN
|
|
814
|
+
describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", () => {
|
|
684
815
|
it("未配置市场地址 → 503 可读错误(「未配置市场地址」卡片区口径,PRD §5.3)", async () => {
|
|
685
816
|
const api = createMarketApi(makeEnv({ cdnBase: async () => null }));
|
|
686
817
|
await api.index.get();
|
|
@@ -688,7 +819,7 @@ describe("GET index(CDN bundles.yaml no-cache 拉取 + 本地 state 对比)"
|
|
|
688
819
|
expect(String(last?.msg)).toContain("未配置市场地址");
|
|
689
820
|
});
|
|
690
821
|
|
|
691
|
-
it("聚合对比:CDN 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/trust/key_id/operator(P2-11
|
|
822
|
+
it("聚合对比:CDN 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/trust/key_id/operator(P2-11);本地独有方案也列出;拉取不带 no-cache", async () => {
|
|
692
823
|
const env = makeEnv();
|
|
693
824
|
const state = await createStateStore(env.statePath);
|
|
694
825
|
await state.write({
|
|
@@ -700,12 +831,13 @@ describe("GET index(CDN bundles.yaml no-cache 拉取 + 本地 state 对比)"
|
|
|
700
831
|
const api = createMarketApi(env);
|
|
701
832
|
await api.index.get();
|
|
702
833
|
expect(last?.status).toBe(200);
|
|
703
|
-
expect(fetchCalls[0]).toBe(`${CDN}
|
|
704
|
-
|
|
834
|
+
expect(fetchCalls[0]).toBe(`${CDN}catalog.yaml`);
|
|
835
|
+
// REG-CS5④:读面不再发 cache-control: no-cache(服务端缓存头 + 条件请求承载新鲜度)
|
|
836
|
+
expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["cache-control"] === undefined)).toBe(true);
|
|
705
837
|
expect(last?.data).toEqual([
|
|
706
|
-
{ name: "demo-solution", latest: "0.2.0", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.
|
|
707
|
-
{ name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, trust_state: null, key_id: null, operator: null },
|
|
708
|
-
{ name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, trust_state: null, key_id: null, operator: null },
|
|
838
|
+
{ name: "demo-solution", latest: "0.2.0", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.1.0", "0.2.0"], changelog: ["0.2.0 变更条目"], publisher: "oj-module", license: "MIT", trust_state: "trusted", key_id: "test.publisher", operator: null },
|
|
839
|
+
{ name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, license: null, trust_state: null, key_id: null, operator: null },
|
|
840
|
+
{ name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, license: null, trust_state: null, key_id: null, operator: null },
|
|
709
841
|
]);
|
|
710
842
|
});
|
|
711
843
|
|
|
@@ -721,21 +853,17 @@ describe("GET index(CDN bundles.yaml no-cache 拉取 + 本地 state 对比)"
|
|
|
721
853
|
expect(rows.find(r => r.name === "wiki")?.operator).toBeNull();
|
|
722
854
|
});
|
|
723
855
|
|
|
724
|
-
it("
|
|
856
|
+
it("shards/1.yaml 签名不可达 → 502 index_sig_missing 不降级(REG-CS1 fail-closed:可用性不再压过完整性)", async () => {
|
|
725
857
|
const fetchFn = (async (url: string) => {
|
|
726
|
-
if (String(url).endsWith("
|
|
858
|
+
if (String(url).endsWith("shards/1.yaml.sig"))
|
|
727
859
|
return { ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
728
860
|
return cdnFetch(url);
|
|
729
861
|
}) as FetchFace;
|
|
730
862
|
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
731
863
|
g.http = { user: { id: 1 } };
|
|
732
864
|
await api.index.get();
|
|
733
|
-
expect(last?.status).toBe(
|
|
734
|
-
|
|
735
|
-
const demo = rows.find(r => r.name === "demo-solution");
|
|
736
|
-
expect(demo?.changelog).toBeNull();
|
|
737
|
-
expect(demo?.publisher).toBeNull();
|
|
738
|
-
expect(demo?.latest).toBe("0.2.0");
|
|
865
|
+
expect(last?.status).toBe(502);
|
|
866
|
+
expect(String(last?.msg)).toContain("index_sig_missing");
|
|
739
867
|
});
|
|
740
868
|
|
|
741
869
|
it("P2-12 bootstrap key(G3 双轨第一轨):信任库为空 + bootstrapKey 注入 → cdn 首装直达 done 不落 needs_trust", async () => {
|
|
@@ -763,14 +891,20 @@ describe("GET index(CDN bundles.yaml no-cache 拉取 + 本地 state 对比)"
|
|
|
763
891
|
|
|
764
892
|
it("archive.file 邪例表(P2-11 索引投毒加固:file 逐字 <name>-<version>.tgz)→ 502 拒", async () => {
|
|
765
893
|
for (const file of ["../evil.tgz", "/etc/hosts.tgz", "other-0.1.0.tgz", "demo-solution-9.9.9.tgz"]) {
|
|
766
|
-
const
|
|
767
|
-
const
|
|
894
|
+
const catalog = JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 1 }, solutions: [{ name: "demo-solution", shards: [1] }] });
|
|
895
|
+
const base = manifestFixture("0.1.0");
|
|
896
|
+
const shard = JSON.stringify({ solutions: [{ name: "demo-solution", latest: "0.1.0", versions: [{ ...base, archive: { ...base.archive, file } }] }] });
|
|
897
|
+
// 本用例聚焦 file 逐字校验:索引照常签名,否则会先被「缺签名」挡下而测不到 file 分支
|
|
898
|
+
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
899
|
+
const shardSig = JSON.stringify(signIndex("shard", 1, shard, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
768
900
|
const fetchFn = (async (url: string) => {
|
|
769
|
-
const text = String(url).endsWith("
|
|
901
|
+
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? shard : null;
|
|
770
902
|
if (text !== null)
|
|
771
903
|
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
772
|
-
if (String(url).endsWith(".yaml.sig"))
|
|
773
|
-
|
|
904
|
+
if (String(url).endsWith(".yaml.sig")) {
|
|
905
|
+
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
906
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
907
|
+
}
|
|
774
908
|
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer }; // .tgz 下载字节
|
|
775
909
|
}) as FetchFace;
|
|
776
910
|
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
@@ -790,22 +924,46 @@ describe("GET index(CDN bundles.yaml no-cache 拉取 + 本地 state 对比)"
|
|
|
790
924
|
});
|
|
791
925
|
});
|
|
792
926
|
|
|
793
|
-
describe("
|
|
794
|
-
/** 注入 .sig
|
|
795
|
-
function signingCdnFetch(sigs: {
|
|
927
|
+
describe("REG-CS1 索引签名强制(G4 fail-closed:署名相符采用 / 篡改拒 / 缺失即拒 / 不同代即拒)", () => {
|
|
928
|
+
/** 注入 .sig 的三级 CDN:非空 .sig 进验签分支;缺/空 .sig → 404(REG-CS1 起即拒,不再容忍) */
|
|
929
|
+
function signingCdnFetch(sigs: { catalog?: string, shard?: string }): FetchFace {
|
|
796
930
|
return ((url: string, init?: unknown) => {
|
|
797
931
|
fetchCalls.push(url);
|
|
798
932
|
fetchInits.push(init);
|
|
799
933
|
if (String(url).endsWith(".yaml.sig")) {
|
|
800
|
-
const kind = String(url).includes("
|
|
934
|
+
const kind = String(url).includes("shards") ? "shard" : "catalog";
|
|
801
935
|
const body = sigs[kind];
|
|
802
936
|
if (body !== undefined && body.trim() !== "")
|
|
803
937
|
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(body).buffer as ArrayBuffer };
|
|
804
938
|
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; // 缺失/空 .sig
|
|
805
939
|
}
|
|
806
|
-
const text = String(url).endsWith("
|
|
807
|
-
?
|
|
808
|
-
: String(url).endsWith("
|
|
940
|
+
const text = String(url).endsWith("catalog.yaml")
|
|
941
|
+
? CATALOG
|
|
942
|
+
: String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
943
|
+
if (text !== null)
|
|
944
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
945
|
+
if (String(url).endsWith(".tgz"))
|
|
946
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
947
|
+
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
948
|
+
}) as FetchFace;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* 代次可配的三级 CDN:catalog 正文 seq、catalog 签名 seq、catalog 对 shard 1 的钉值、
|
|
953
|
+
* shard 1 签名 seq 分别可设——用来造「新总目录 + 旧分片」「改了正文没换签名」等中间态。
|
|
954
|
+
* 分片正文恒为 SHARD_1(纯内容无 seq:代次只由签名与 catalog 钉值携带)。
|
|
955
|
+
*/
|
|
956
|
+
function generationCdnFetch(args: { catalogSeq: number, shardPin: number, shardSigSeq: number }): FetchFace {
|
|
957
|
+
const catalog = JSON.stringify({ seq: args.catalogSeq, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: args.shardPin }, solutions: [{ name: "demo-solution", shards: [1] }] });
|
|
958
|
+
const catalogSig = JSON.stringify(signIndex("catalog", args.catalogSeq, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
959
|
+
const shardSig = JSON.stringify(signIndex("shard", args.shardSigSeq, SHARD_1, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
960
|
+
return ((url: string) => {
|
|
961
|
+
fetchCalls.push(url);
|
|
962
|
+
if (String(url).endsWith(".yaml.sig")) {
|
|
963
|
+
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
964
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
965
|
+
}
|
|
966
|
+
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
809
967
|
if (text !== null)
|
|
810
968
|
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
811
969
|
if (String(url).endsWith(".tgz"))
|
|
@@ -814,33 +972,428 @@ describe("P3-7 索引签名校验(G4 fail-closed:署名相符采用 / 篡改
|
|
|
814
972
|
}) as FetchFace;
|
|
815
973
|
}
|
|
816
974
|
|
|
817
|
-
it("署名相符:
|
|
818
|
-
const
|
|
819
|
-
const manifestsSig = JSON.stringify(signIndex("manifests", MANIFESTS, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
820
|
-
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ bundles: bundlesSig, manifests: manifestsSig }) }));
|
|
975
|
+
it("署名相符:catalog/shard 均带合法 .sig → index 照常 200(G4 采用,验签分支确已触发)", async () => {
|
|
976
|
+
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ catalog: CATALOG_SIG, shard: SHARD_1_SIG }) }));
|
|
821
977
|
g.http = { user: { id: 1 } };
|
|
822
978
|
await api.index.get();
|
|
823
979
|
expect(last?.status).toBe(200);
|
|
824
980
|
expect((last?.data as unknown[]).length).toBeGreaterThan(0);
|
|
825
|
-
expect(fetchCalls.some(u => u.endsWith("
|
|
826
|
-
expect(fetchCalls.some(u => u.endsWith("
|
|
981
|
+
expect(fetchCalls.some(u => u.endsWith("catalog.yaml.sig"))).toBe(true);
|
|
982
|
+
expect(fetchCalls.some(u => u.endsWith("shards/1.yaml.sig"))).toBe(true);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("篡改拒:catalog .sig 对别的字节签名(非索引真值)→ verifyIndexSig 不符 → 502 fail-closed(G4 不吞)", async () => {
|
|
986
|
+
const catalogSig = JSON.stringify(signIndex("catalog", SEQ, `${CATALOG}\n# tampered`, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
987
|
+
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ catalog: catalogSig, shard: SHARD_1_SIG }) }));
|
|
988
|
+
g.http = { user: { id: 1 } };
|
|
989
|
+
await api.index.get();
|
|
990
|
+
expect(last?.status).toBe(502);
|
|
991
|
+
expect(String(last?.msg)).toContain("index_sig_mismatch");
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("缺失即拒:CDN 不回 .sig → 502 index_sig_missing(不再「滚动发布期容忍」)+两步补签提示", async () => {
|
|
995
|
+
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({}) }));
|
|
996
|
+
g.http = { user: { id: 1 } };
|
|
997
|
+
await api.index.get();
|
|
998
|
+
expect(last?.status).toBe(502);
|
|
999
|
+
expect(String(last?.msg)).toContain("index_sig_missing");
|
|
1000
|
+
expect(String(last?.msg)).toContain("ojm market build-index");
|
|
1001
|
+
expect(String(last?.msg)).toContain("ojm publish");
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("空 .sig 等同缺失 → 502 index_sig_missing(不能靠空体绕过)", async () => {
|
|
1005
|
+
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ catalog: " ", shard: " " }) }));
|
|
1006
|
+
g.http = { user: { id: 1 } };
|
|
1007
|
+
await api.index.get();
|
|
1008
|
+
expect(last?.status).toBe(502);
|
|
1009
|
+
expect(String(last?.msg)).toContain("index_sig_missing");
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("签名未覆盖代次(旧格式无 seq)→ 502 index_sig_mismatch(不默认通过)", async () => {
|
|
1013
|
+
const legacy = JSON.stringify({ alg: "Ed25519", key_id: "test.publisher", public_key: TEST_KEY.publicKeyHex, value: "AAAA", canon: "v1" });
|
|
1014
|
+
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ catalog: legacy, shard: legacy }) }));
|
|
1015
|
+
g.http = { user: { id: 1 } };
|
|
1016
|
+
await api.index.get();
|
|
1017
|
+
expect(last?.status).toBe(502);
|
|
1018
|
+
expect(String(last?.msg)).toContain("index_sig_mismatch");
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it("跨文件不同代(红队 F2):总目录第 7 代钉 shard 7、分片签名第 6 代 → 502 index_generation_mismatch(提示源在重建,不是「被篡改」)", async () => {
|
|
1022
|
+
const api = createMarketApi(makeEnv({ fetch: generationCdnFetch({ catalogSeq: 7, shardPin: 7, shardSigSeq: 6 }) }));
|
|
1023
|
+
g.http = { user: { id: 1 } };
|
|
1024
|
+
await api.index.get();
|
|
1025
|
+
expect(last?.status).toBe(502);
|
|
1026
|
+
expect(String(last?.msg)).toContain("index_generation_mismatch");
|
|
1027
|
+
expect(String(last?.msg)).toContain("源正在重建索引,请稍后重试");
|
|
1028
|
+
expect(String(last?.msg)).not.toContain("被篡改");
|
|
827
1029
|
});
|
|
828
1030
|
|
|
829
|
-
it("
|
|
830
|
-
|
|
831
|
-
const
|
|
1031
|
+
it("正文与签名不同代(改了正文没换签名)→ 502 index_generation_mismatch", async () => {
|
|
1032
|
+
// catalog 正文 seq=7 但签名 seq=6:body-seq 与 sig-seq 对不上(半个写入)
|
|
1033
|
+
const catalog = JSON.stringify({ seq: 7, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 6 }, solutions: [{ name: "demo-solution", shards: [1] }] });
|
|
1034
|
+
const catalogSig = JSON.stringify(signIndex("catalog", 6, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1035
|
+
const shardSig = JSON.stringify(signIndex("shard", 6, SHARD_1, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1036
|
+
const fetchFn = ((url: string) => {
|
|
1037
|
+
fetchCalls.push(url);
|
|
1038
|
+
if (String(url).endsWith(".yaml.sig")) {
|
|
1039
|
+
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
1040
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
1041
|
+
}
|
|
1042
|
+
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
1043
|
+
if (text !== null)
|
|
1044
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1045
|
+
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1046
|
+
}) as FetchFace;
|
|
1047
|
+
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
832
1048
|
g.http = { user: { id: 1 } };
|
|
833
1049
|
await api.index.get();
|
|
834
1050
|
expect(last?.status).toBe(502);
|
|
835
|
-
expect(String(last?.msg)).toContain("
|
|
1051
|
+
expect(String(last?.msg)).toContain("index_generation_mismatch");
|
|
836
1052
|
});
|
|
837
1053
|
|
|
838
|
-
it("
|
|
839
|
-
const api = createMarketApi(makeEnv({ fetch:
|
|
1054
|
+
it("同代放行:catalog 第 7 代、钉 shard 7、分片签名第 7 代 → 200(不误伤正常重建)", async () => {
|
|
1055
|
+
const api = createMarketApi(makeEnv({ fetch: generationCdnFetch({ catalogSeq: 7, shardPin: 7, shardSigSeq: 7 }) }));
|
|
840
1056
|
g.http = { user: { id: 1 } };
|
|
841
1057
|
await api.index.get();
|
|
842
1058
|
expect(last?.status).toBe(200);
|
|
843
|
-
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it("byte 相同分片跨代复用:catalog 第 7 代钉 shard 3、分片签名第 3 代 → 200(钉值口径,非恒等 catalog 代次)", async () => {
|
|
1062
|
+
const api = createMarketApi(makeEnv({ fetch: generationCdnFetch({ catalogSeq: 7, shardPin: 3, shardSigSeq: 3 }) }));
|
|
1063
|
+
g.http = { user: { id: 1 } };
|
|
1064
|
+
await api.index.get();
|
|
1065
|
+
expect(last?.status).toBe(200);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it("安装侧同判定:缺签名 → install 502 index_sig_missing(离线 verify 与站点安装不能一个拒一个放)", async () => {
|
|
1069
|
+
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({}) }));
|
|
1070
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
1071
|
+
await api.install.post();
|
|
1072
|
+
expect(last?.status).toBe(502);
|
|
1073
|
+
expect(String(last?.msg)).toContain("index_sig_missing");
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
describe("REG-CS2 索引新鲜度(过期即拒 / 代次回退即拒 / 状态损坏 fail-closed)", () => {
|
|
1078
|
+
/** 代次与时效可配的三级 CDN:catalog 正文 seq 与时效封套可设;分片与 catalog 同代(钉值 = seq,签名 seq = 钉值) */
|
|
1079
|
+
function timedCdnFetch(args: { seq: number, shardPin?: number, expiresAt?: number | null }): FetchFace {
|
|
1080
|
+
const pin = args.shardPin ?? args.seq;
|
|
1081
|
+
const catalog = JSON.stringify({
|
|
1082
|
+
seq: args.seq,
|
|
1083
|
+
// expiresAt = null → 索引不带时效封套(旧 ojm 产物)
|
|
1084
|
+
...(args.expiresAt === null ? {} : { issued_at: ISSUED_AT, expires_at: args.expiresAt ?? EXPIRES_AT }),
|
|
1085
|
+
shards: { 1: pin },
|
|
1086
|
+
solutions: [{ name: "demo-solution", shards: [1] }],
|
|
1087
|
+
});
|
|
1088
|
+
const catalogSig = JSON.stringify(signIndex("catalog", args.seq, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1089
|
+
const shardSig = JSON.stringify(signIndex("shard", pin, SHARD_1, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1090
|
+
return ((url: string) => {
|
|
1091
|
+
fetchCalls.push(url);
|
|
1092
|
+
if (String(url).endsWith(".yaml.sig")) {
|
|
1093
|
+
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
1094
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
1095
|
+
}
|
|
1096
|
+
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
1097
|
+
if (text !== null)
|
|
1098
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1099
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
1100
|
+
}) as FetchFace;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
it("索引已过期(expires_at 已过)→ 502 index_expired", async () => {
|
|
1104
|
+
const api = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 1, expiresAt: ISSUED_AT - 1 }) }));
|
|
1105
|
+
g.http = { user: { id: 1 } };
|
|
1106
|
+
await api.index.get();
|
|
1107
|
+
expect(last?.status).toBe(502);
|
|
1108
|
+
expect(String(last?.msg)).toContain("index_expired");
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it("索引不带时效(旧 ojm 产物)→ 502 index_expired(不可判定即按过期处理)", async () => {
|
|
1112
|
+
const api = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 1, expiresAt: null }) }));
|
|
1113
|
+
g.http = { user: { id: 1 } };
|
|
1114
|
+
await api.index.get();
|
|
1115
|
+
expect(last?.status).toBe(502);
|
|
1116
|
+
expect(String(last?.msg)).toContain("index_expired");
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it("源返回更旧的代次(镜像落后 / 旧快照回放)→ 502 index_stale,且水位不被拉低", async () => {
|
|
1120
|
+
// 先见第 7 代(水位落库),同源再返回第 3 代 → 回放被挡
|
|
1121
|
+
const seen7 = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 7 }) }));
|
|
1122
|
+
g.http = { user: { id: 1 } };
|
|
1123
|
+
await seen7.index.get();
|
|
1124
|
+
expect(last?.status).toBe(200);
|
|
1125
|
+
|
|
1126
|
+
const replayed = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 3 }) }));
|
|
1127
|
+
g.http = { user: { id: 1 } };
|
|
1128
|
+
await replayed.index.get();
|
|
1129
|
+
expect(last?.status).toBe(502);
|
|
1130
|
+
expect(String(last?.msg)).toContain("index_stale");
|
|
1131
|
+
const row = dbo.prepare("SELECT max_seq FROM reg_source_seq WHERE source_url = ?").get(CDN.replace(/\/$/, "")) as { max_seq: number } | undefined;
|
|
1132
|
+
expect(row?.max_seq).toBe(7);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it("同代重复拉取 → 放行(幂等,不误伤正常重复请求)", async () => {
|
|
1136
|
+
const fetchFn = timedCdnFetch({ seq: 4 });
|
|
1137
|
+
g.http = { user: { id: 1 } };
|
|
1138
|
+
await createMarketApi(makeEnv({ fetch: fetchFn })).index.get();
|
|
1139
|
+
expect(last?.status).toBe(200);
|
|
1140
|
+
await createMarketApi(makeEnv({ fetch: fetchFn })).index.get();
|
|
1141
|
+
expect(last?.status).toBe(200);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("代次水位表缺失(未 migrate)→ 502 index_state_corrupt(fail-closed,不静默当「第一次见」)", async () => {
|
|
1145
|
+
dbo.exec("DROP TABLE reg_source_seq");
|
|
1146
|
+
const api = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 1 }) }));
|
|
1147
|
+
g.http = { user: { id: 1 } };
|
|
1148
|
+
await api.index.get();
|
|
1149
|
+
expect(last?.status).toBe(502);
|
|
1150
|
+
expect(String(last?.msg)).toContain("index_state_corrupt");
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
describe("REG-CS5 三级索引(装箱分片 + 条件请求 + DIP 读取源 + 读写两侧半写防线)", () => {
|
|
1155
|
+
/**
|
|
1156
|
+
* 2500 版本跨 3 片的装箱索引(BDD①;装箱口径与写侧 sharding.ts 同参:单片 ≤1000 版本):
|
|
1157
|
+
* 1 号片持 1.0.0..1.0.999、2 号片持 1.0.1000..1.0.1999、3 号片持 1.0.2000..1.0.2499,
|
|
1158
|
+
* 最高片持最新版。两端界版本给完整 Form B manifest(安装链消费面),
|
|
1159
|
+
* 其余条目只要 name/version/archive 三要素(latest 单调与 available 消费)。
|
|
1160
|
+
*/
|
|
1161
|
+
function bigIndexFetch(): FetchFace {
|
|
1162
|
+
const cut = 1000;
|
|
1163
|
+
const entryOf = (v: string, full: boolean): Record<string, unknown> => full
|
|
1164
|
+
? manifestFixture(v)
|
|
1165
|
+
: { name: "demo-solution", version: v, archive: { file: `demo-solution-${v}.tgz`, sha256: SHA256, size: ARCHIVE.byteLength } };
|
|
1166
|
+
const range = (from: number, to: number, fullAt: number[]): Array<Record<string, unknown>> =>
|
|
1167
|
+
Array.from({ length: to - from + 1 }, (_, i) => entryOf(`1.0.${from + i}`, fullAt.includes(from + i)));
|
|
1168
|
+
const shard1 = JSON.stringify({ solutions: [{ name: "demo-solution", latest: `1.0.${cut - 1}`, versions: range(0, cut - 1, [0]) }] });
|
|
1169
|
+
const shard2 = JSON.stringify({ solutions: [{ name: "demo-solution", latest: `1.0.${cut * 2 - 1}`, versions: range(cut, cut * 2 - 1, []) }] });
|
|
1170
|
+
const shard3 = JSON.stringify({ solutions: [{ name: "demo-solution", latest: "1.0.2499", versions: range(cut * 2, 2499, [2499]) }] });
|
|
1171
|
+
const catalog = JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 1, 2: 1, 3: 1 }, solutions: [{ name: "demo-solution", shards: [1, 2, 3] }] });
|
|
1172
|
+
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1173
|
+
const shardSigs = [shard1, shard2, shard3].map(body => JSON.stringify(signIndex("shard", 1, body, TEST_KEY.privateKeyPem, "test.publisher")));
|
|
1174
|
+
return ((url: string) => {
|
|
1175
|
+
fetchCalls.push(url);
|
|
1176
|
+
if (String(url).endsWith(".yaml.sig")) {
|
|
1177
|
+
const hit = /shards\/(\d+)\.yaml\.sig$/.exec(String(url));
|
|
1178
|
+
const sig = hit === null ? catalogSig : shardSigs[Number(hit[1]) - 1]!;
|
|
1179
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
1180
|
+
}
|
|
1181
|
+
const text = String(url).endsWith("catalog.yaml")
|
|
1182
|
+
? catalog
|
|
1183
|
+
: String(url).endsWith("shards/1.yaml") ? shard1 : String(url).endsWith("shards/2.yaml") ? shard2 : String(url).endsWith("shards/3.yaml") ? shard3 : null;
|
|
1184
|
+
if (text !== null)
|
|
1185
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1186
|
+
if (String(url).endsWith(".tgz"))
|
|
1187
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
1188
|
+
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1189
|
+
}) as FetchFace;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
it("BDD① 装箱分片:2500 版本跨 3 片,latest 安装只拉最高片(≤ 1 catalog + 1 shard)→ done", async () => {
|
|
1193
|
+
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
|
|
1194
|
+
fetchCalls.length = 0;
|
|
1195
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
|
|
1196
|
+
await api.install.post();
|
|
1197
|
+
expect(last?.status).toBe(200);
|
|
1198
|
+
expect(fetchCalls).toContain(`${CDN}catalog.yaml`);
|
|
1199
|
+
expect(fetchCalls).toContain(`${CDN}shards/3.yaml`);
|
|
1200
|
+
// 降序尝试:最高片即中,低片一次都不拉(索引请求 ≤ 1 catalog + 1 shard)
|
|
1201
|
+
expect(fetchCalls.some(u => u.endsWith("shards/1.yaml"))).toBe(false);
|
|
1202
|
+
expect(fetchCalls.some(u => u.endsWith("shards/2.yaml"))).toBe(false);
|
|
1203
|
+
expect(fetchCalls).toContain(`${CDN}demo-solution-1.0.2499.tgz`);
|
|
1204
|
+
const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
|
|
1205
|
+
const rec = await getTask(taskDir, taskId);
|
|
1206
|
+
expect(rec?.version).toBe("1.0.2499");
|
|
1207
|
+
expect(rec?.status).toBe("done");
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it("BDD① 回落低片:显式指定最低版本 → 先试最高片未中,逐片回落到 1 号片命中 → done", async () => {
|
|
1211
|
+
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
|
|
1212
|
+
fetchCalls.length = 0;
|
|
1213
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "1.0.0", source: "cdn" } };
|
|
1214
|
+
await api.install.post();
|
|
1215
|
+
expect(last?.status).toBe(200);
|
|
1216
|
+
expect(fetchCalls).toContain(`${CDN}shards/3.yaml`); // 仍先试最高片
|
|
1217
|
+
expect(fetchCalls).toContain(`${CDN}shards/2.yaml`); // 未中 → 回落中片
|
|
1218
|
+
expect(fetchCalls).toContain(`${CDN}shards/1.yaml`); // 仍未中 → 回落低片命中
|
|
1219
|
+
expect(fetchCalls).toContain(`${CDN}demo-solution-1.0.0.tgz`);
|
|
1220
|
+
const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
|
|
1221
|
+
const rec = await getTask(taskDir, taskId);
|
|
1222
|
+
expect(rec?.version).toBe("1.0.0");
|
|
1223
|
+
expect(rec?.status).toBe("done");
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it("页面 available 全集(回滚候选要版本全集):3 片 2500 版跨片全局升序,latest 取最高片", async () => {
|
|
1227
|
+
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
|
|
1228
|
+
g.http = { user: { id: 1 } };
|
|
1229
|
+
await api.index.get();
|
|
1230
|
+
expect(last?.status).toBe(200);
|
|
1231
|
+
const row = (last?.data as Array<{ name: string, latest: string | null, available: string[], changelog: string[] | null, publisher: string | null }>).find(r => r.name === "demo-solution");
|
|
1232
|
+
expect(row?.latest).toBe("1.0.2499");
|
|
1233
|
+
expect(row?.available).toHaveLength(2500);
|
|
1234
|
+
expect(row?.available[0]).toBe("1.0.0");
|
|
1235
|
+
expect(row?.available[999]).toBe("1.0.999"); // 1 号片末条
|
|
1236
|
+
expect(row?.available[1000]).toBe("1.0.1000"); // 2 号片首条——跨片拼接处仍升序
|
|
1237
|
+
expect(row?.available[1999]).toBe("1.0.1999"); // 2 号片末条
|
|
1238
|
+
expect(row?.available[2000]).toBe("1.0.2000"); // 3 号片首条
|
|
1239
|
+
expect(row?.available[2499]).toBe("1.0.2499");
|
|
1240
|
+
expect([...row!.available].sort(compareVersions)).toEqual(row!.available);
|
|
1241
|
+
// latest 的 changelog/publisher 直接取分片条目(无独立聚合清单)
|
|
1242
|
+
expect(row?.changelog).toEqual(["1.0.2499 变更条目"]);
|
|
1243
|
+
expect(row?.publisher).toBe("oj-module");
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it("条件请求缓存(Q8 可用性通道):第二轮索引拉取带 If-None-Match → 304 不重传正文,缓存副本照常出数", async () => {
|
|
1247
|
+
// 独立基址:etagCache 是模块级(url → {etag, body}),与其余用例的 CDN 基址隔离
|
|
1248
|
+
const ETAG_BASE = "https://etag.example.com/cdn/";
|
|
1249
|
+
let notModified = 0;
|
|
1250
|
+
const fetchFn = ((url: string, init?: unknown) => {
|
|
1251
|
+
fetchCalls.push(url);
|
|
1252
|
+
fetchInits.push(init);
|
|
1253
|
+
const conditional = (init as { headers?: Record<string, string> } | undefined)?.headers?.["if-none-match"];
|
|
1254
|
+
const text = String(url).endsWith("catalog.yaml")
|
|
1255
|
+
? CATALOG
|
|
1256
|
+
: String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
1257
|
+
const sigText = String(url).endsWith("catalog.yaml.sig")
|
|
1258
|
+
? CATALOG_SIG
|
|
1259
|
+
: String(url).endsWith("shards/1.yaml.sig") ? SHARD_1_SIG : null;
|
|
1260
|
+
// 带条件头的请求:服务端认 ETag → 304 空正文(ok=false 但语义是「未变」)
|
|
1261
|
+
if (conditional !== undefined) {
|
|
1262
|
+
notModified++;
|
|
1263
|
+
return { ok: false, status: 304, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1264
|
+
}
|
|
1265
|
+
const body = text ?? sigText;
|
|
1266
|
+
if (body !== null) {
|
|
1267
|
+
return {
|
|
1268
|
+
ok: true,
|
|
1269
|
+
status: 200,
|
|
1270
|
+
arrayBuffer: async () => new TextEncoder().encode(body).buffer as ArrayBuffer,
|
|
1271
|
+
// 响应头读取面(site-facade 扩展):读 ETag 供下一轮 If-None-Match
|
|
1272
|
+
headers: { get: (name: string) => (name.toLowerCase() === "etag" ? `"idx-${body.length}"` : null) },
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1276
|
+
}) as FetchFace;
|
|
1277
|
+
const api = createMarketApi(makeEnv({ cdnBase: async () => ETAG_BASE, fetch: fetchFn }));
|
|
1278
|
+
g.http = { user: { id: 1 } };
|
|
1279
|
+
// 第一轮:无缓存 → 四项索引请求(catalog 正文+签名、shard 正文+签名)全量 200 + ETag
|
|
1280
|
+
await api.index.get();
|
|
1281
|
+
expect(last?.status).toBe(200);
|
|
1282
|
+
const first = last?.data;
|
|
1283
|
+
expect(notModified).toBe(0);
|
|
1284
|
+
expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["if-none-match"] === undefined)).toBe(true);
|
|
1285
|
+
// 读面不发 no-cache:新鲜度由服务端缓存头 + 条件请求承载
|
|
1286
|
+
expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["cache-control"] === undefined)).toBe(true);
|
|
1287
|
+
// 第二轮:四项全部带 If-None-Match → 全部 304 不重传,缓存副本出数与首轮逐字一致
|
|
1288
|
+
await api.index.get();
|
|
1289
|
+
expect(last?.status).toBe(200);
|
|
1290
|
+
expect(last?.data).toEqual(first);
|
|
1291
|
+
const second = fetchInits.slice(4);
|
|
1292
|
+
expect(second).toHaveLength(4);
|
|
1293
|
+
expect(second.every(i => (i as { headers?: Record<string, string> }).headers?.["if-none-match"] !== undefined)).toBe(true);
|
|
1294
|
+
expect(notModified).toBe(4);
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it("DIP 本地目录索引源:indexSource 注入 → 零 HTTP 直读 catalog+shards,校验语义与 HTTP 实现同构", async () => {
|
|
1298
|
+
const dir = join(root, "cdn-mirror");
|
|
1299
|
+
await fsp.mkdir(join(dir, "shards"), { recursive: true });
|
|
1300
|
+
await fsp.writeFile(join(dir, "catalog.yaml"), CATALOG);
|
|
1301
|
+
await fsp.writeFile(join(dir, "catalog.yaml.sig"), CATALOG_SIG);
|
|
1302
|
+
await fsp.writeFile(join(dir, "shards", "1.yaml"), SHARD_1);
|
|
1303
|
+
await fsp.writeFile(join(dir, "shards", "1.yaml.sig"), SHARD_1_SIG);
|
|
1304
|
+
const api = createMarketApi(makeEnv({ indexSource: localIndexSource(dir) }));
|
|
1305
|
+
fetchCalls.length = 0;
|
|
1306
|
+
g.http = { user: { id: 1 } };
|
|
1307
|
+
await api.index.get();
|
|
1308
|
+
expect(last?.status).toBe(200);
|
|
1309
|
+
expect(fetchCalls.length).toBe(0); // 本地直读,零出站
|
|
1310
|
+
const row = (last?.data as Array<{ name: string, latest: string | null }>).find(r => r.name === "demo-solution");
|
|
1311
|
+
expect(row?.latest).toBe("0.2.0");
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
it("本地目录索引源同构拒载:缺 .sig → 502 index_sig_missing(与 HTTP 实现一个判定)", async () => {
|
|
1315
|
+
const dir = join(root, "cdn-mirror-nosig");
|
|
1316
|
+
await fsp.mkdir(join(dir, "shards"), { recursive: true });
|
|
1317
|
+
await fsp.writeFile(join(dir, "catalog.yaml"), CATALOG);
|
|
1318
|
+
await fsp.writeFile(join(dir, "shards", "1.yaml"), SHARD_1);
|
|
1319
|
+
await fsp.writeFile(join(dir, "shards", "1.yaml.sig"), SHARD_1_SIG); // 只缺 catalog 的签名
|
|
1320
|
+
const api = createMarketApi(makeEnv({ indexSource: localIndexSource(dir) }));
|
|
1321
|
+
g.http = { user: { id: 1 } };
|
|
1322
|
+
await api.index.get();
|
|
1323
|
+
expect(last?.status).toBe(502);
|
|
1324
|
+
expect(String(last?.msg)).toContain("index_sig_missing");
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
it("读侧半写防线:catalog 钉了 shards/2.yaml 但 CDN 缺该文件 → 502 fail-closed(不是「没有该方案」)", async () => {
|
|
1328
|
+
// 写侧原子落地序:分片正文 → 分片签名 → catalog 正文 → catalog 签名。
|
|
1329
|
+
// 此夹具 = catalog 已提交、shard 2 尚未落盘的中间态(读侧视角的半写)
|
|
1330
|
+
const catalog = JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 1, 2: 1 }, solutions: [{ name: "demo-solution", shards: [1, 2] }] });
|
|
1331
|
+
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1332
|
+
const fetchFn = ((url: string) => {
|
|
1333
|
+
fetchCalls.push(url);
|
|
1334
|
+
if (String(url).includes("shards/2"))
|
|
1335
|
+
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; // 分片还没落盘
|
|
1336
|
+
if (String(url).endsWith("catalog.yaml.sig"))
|
|
1337
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(catalogSig).buffer as ArrayBuffer };
|
|
1338
|
+
if (String(url).endsWith("shards/1.yaml.sig"))
|
|
1339
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(SHARD_1_SIG).buffer as ArrayBuffer };
|
|
1340
|
+
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
1341
|
+
if (text !== null)
|
|
1342
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1343
|
+
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1344
|
+
}) as FetchFace;
|
|
1345
|
+
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
1346
|
+
g.http = { user: { id: 1 } };
|
|
1347
|
+
await api.index.get();
|
|
1348
|
+
expect(last?.status).toBe(502);
|
|
1349
|
+
expect(String(last?.msg)).toContain("shards/2.yaml");
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it("索引结构破损邪例表(签名过的不可信输入,G4:结构破损与字节被改同类)→ 502 index_generation_mismatch", async () => {
|
|
1353
|
+
const brokenCatalogs = [
|
|
1354
|
+
// 分片钉值为空
|
|
1355
|
+
JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: {}, solutions: [{ name: "demo-solution", shards: [1] }] }),
|
|
1356
|
+
// 方案指向未钉分片(不静默跳过——那会伪装成「没有该方案」)
|
|
1357
|
+
JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 1 }, solutions: [{ name: "demo-solution", shards: [2] }] }),
|
|
1358
|
+
];
|
|
1359
|
+
for (const catalog of brokenCatalogs) {
|
|
1360
|
+
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1361
|
+
const fetchFn = ((url: string) => {
|
|
1362
|
+
if (String(url).endsWith("catalog.yaml.sig"))
|
|
1363
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(catalogSig).buffer as ArrayBuffer };
|
|
1364
|
+
if (String(url).endsWith("catalog.yaml"))
|
|
1365
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(catalog).buffer as ArrayBuffer };
|
|
1366
|
+
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1367
|
+
}) as FetchFace;
|
|
1368
|
+
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
1369
|
+
g.http = { user: { id: 1 } };
|
|
1370
|
+
await api.index.get();
|
|
1371
|
+
expect(last?.status).toBe(502);
|
|
1372
|
+
expect(String(last?.msg)).toContain("index_generation_mismatch");
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
it("分片结构破损(签名过但缺 solutions 数组)→ 502 fail-closed(不猜、不降级)", async () => {
|
|
1377
|
+
const shard = JSON.stringify({ packages: [] }); // 旧键名/畸形:分片解析面拒
|
|
1378
|
+
const catalog = JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 1 }, solutions: [{ name: "demo-solution", shards: [1] }] });
|
|
1379
|
+
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1380
|
+
const shardSig = JSON.stringify(signIndex("shard", 1, shard, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1381
|
+
const fetchFn = ((url: string) => {
|
|
1382
|
+
if (String(url).endsWith(".yaml.sig")) {
|
|
1383
|
+
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
1384
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
1385
|
+
}
|
|
1386
|
+
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? shard : null;
|
|
1387
|
+
if (text !== null)
|
|
1388
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1389
|
+
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1390
|
+
}) as FetchFace;
|
|
1391
|
+
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
1392
|
+
g.http = { user: { id: 1 } };
|
|
1393
|
+
await api.index.get();
|
|
1394
|
+
expect(last?.status).toBe(502);
|
|
1395
|
+
expect(String(last?.msg)).toContain("shards/1.yaml");
|
|
1396
|
+
expect(String(last?.msg)).toContain("solutions");
|
|
844
1397
|
});
|
|
845
1398
|
});
|
|
846
1399
|
|
|
@@ -921,16 +1474,19 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
|
|
|
921
1474
|
expect(mig?.opts).toEqual({ timeoutMs: 600000 });
|
|
922
1475
|
});
|
|
923
1476
|
|
|
924
|
-
it("smoke
|
|
925
|
-
|
|
926
|
-
|
|
1477
|
+
it("smoke 探针补全(REG-CS6):按配置的 canonical_origin 补全相对探针,绝不读请求 Host 头", async () => {
|
|
1478
|
+
// 配置 canonical origin;伪造 Host 头(云元数据 IP)在场,验证出站不被其牵引
|
|
1479
|
+
const api = createMarketApi(makeEnv({ canonicalOrigin: "http://site.example:9781" }));
|
|
1480
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" }, headers: { host: "169.254.169.254" } };
|
|
927
1481
|
await api.install.post();
|
|
928
1482
|
expect(last?.status).toBe(200);
|
|
929
1483
|
const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
|
|
930
1484
|
expect((await getTask(taskDir, taskId))?.status).toBe("done");
|
|
931
1485
|
// 夹具 manifest 无 health → 探首个非匿名路由 /demo-solution → env.apiPrefix=/api/demo-solution
|
|
932
|
-
// → 相对探针 /api/demo-solution/demo-solution
|
|
1486
|
+
// → 相对探针 /api/demo-solution/demo-solution 按 canonical_origin 补全为绝对 URL
|
|
933
1487
|
expect(fetchCalls).toContain("http://site.example:9781/api/demo-solution/demo-solution");
|
|
1488
|
+
// 伪造 Host 头(169.254.169.254)绝不牵引出站
|
|
1489
|
+
expect(fetchCalls.some(u => String(u).includes("169.254.169.254"))).toBe(false);
|
|
934
1490
|
// CDN 拉取(绝对 URL)不受补全影响
|
|
935
1491
|
expect(fetchCalls.some(u => u.startsWith("https://cdn.example.com/"))).toBe(true);
|
|
936
1492
|
});
|