@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.
- package/devkit/README.md +10 -8
- package/devkit/framework-dev.md +1 -1
- package/devkit/manual.md +45 -19
- package/devkit/module-dev-and-cdn-playground.md +20 -14
- package/devkit/publish-walkthrough.md +132 -43
- package/devkit/wecom-customer-reauth-guide.md +110 -0
- package/package.json +2 -2
- package/shell-dist/assets/{runtime.zrv2zhj9j0.js → runtime.7x99hmm020.js} +2 -2
- package/shell-dist/bundles/oj-market-0.2.0.tgz +0 -0
- package/shell-dist/bundles/oj-market-0.2.0.tgz.sig +8 -0
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/list.test.ts +7 -7
- package/src/__tests__/oj-health-host.test.ts +19 -0
- package/src/__tests__/site-tenant-header.test.ts +2 -2
- package/src/__tests__/templates-builtin-list.test.ts +47 -0
- package/src/__tests__/templates-schema-columns.test.ts +11 -2
- package/src/__tests__/templates-tenant-scope.test.ts +2 -3
- package/src/__tests__/users-roles-display.test.ts +84 -0
- package/src/bundle.ts +1 -1
- package/src/contract/__tests__/market-client.integration.test.ts +23 -22
- package/src/enable.ts +2 -2
- package/src/init.ts +45 -0
- package/src/install.ts +5 -5
- package/src/lifecycle/__tests__/install.test.ts +29 -29
- package/src/lifecycle/__tests__/market-bundle.test.ts +87 -0
- package/src/lifecycle/__tests__/publish.test.ts +8 -10
- package/src/lifecycle/__tests__/trust.test.ts +14 -14
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/list.ts +3 -3
- package/src/market/__tests__/bootstrap.test.ts +24 -7
- package/src/market/__tests__/failure-playbook.test.ts +0 -1
- package/src/market/__tests__/remote-push.test.ts +228 -0
- package/src/market/__tests__/verify.test.ts +45 -207
- package/src/market/bootstrap.ts +12 -7
- package/src/market/command.ts +33 -35
- package/src/market/db-seed.ts +129 -0
- package/src/market/failure-playbook.ts +3 -9
- package/src/market/fixture.ts +2 -6
- package/src/market/publish.ts +145 -89
- package/src/market/remote-push.ts +236 -0
- package/src/market/trial.ts +2 -2
- package/src/market/verify.ts +17 -270
- package/src/market/walkthrough.ts +38 -63
- package/src/oj.ts +13 -2
- package/src/preview.ts +5 -3
- package/src/publish.ts +3 -2
- package/src/scaffold-guard.ts +4 -0
- package/src/site.ts +1 -1
- package/src/trust.ts +8 -8
- package/src/uninstall.ts +2 -2
- package/src/usage.ts +23 -21
- package/templates/config.yaml +22 -3
- package/templates/init.include.yaml +16 -11
- package/templates/openapi.json +3948 -3655
- package/templates/src/.ojm-builtin.json +13 -0
- package/templates/src/README.md +5 -2
- package/templates/src/app/api/_platform/README.md +3 -2
- package/templates/src/app/api/_platform/_lifecycle/atomic.ts +7 -2
- package/templates/src/app/api/_platform/_lifecycle/bundle-converge.ts +158 -0
- package/templates/src/app/api/_platform/_lifecycle/history.ts +3 -2
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +25 -5
- package/templates/src/app/api/_platform/_lifecycle/local-archive.ts +61 -0
- package/templates/src/app/api/_platform/_lifecycle/manifest-of-upload.ts +62 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +12 -2
- package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +4 -1
- package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +8 -2
- package/templates/src/app/api/_platform/_lifecycle/types.ts +1 -1
- package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +14 -5
- package/templates/src/app/api/_platform/_shared/site-facade.ts +31 -0
- package/templates/src/app/api/_platform/manifest.yaml +1 -2
- package/templates/src/app/api/_platform/migrations/0003__market_catalog.mysql.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0003__market_catalog.pg.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0003__market_catalog.sql +18 -0
- package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.mysql.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.pg.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.sql +6 -0
- package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.mysql.sql +8 -0
- package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.pg.sql +8 -0
- package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.sql +8 -0
- package/templates/src/app/api/_platform/schema.yaml +8 -11
- package/templates/src/app/api/_platform/seed.mysql.sql +1 -1
- package/templates/src/app/api/_platform/seed.pg.sql +1 -1
- package/templates/src/app/api/_platform/seed.sql +1 -1
- package/templates/src/app/api/auth/_shared/wecom/sync.ts +6 -0
- package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -114
- package/templates/src/app/web/_platform/__client/index.ts +1 -333
- package/templates/src/app/web/system/locales/en-US.json +15 -1
- package/templates/src/app/web/system/locales/zh-CN.json +15 -1
- package/templates/src/app/web/system/pages/departments/sync-bar.tsx +9 -3
- package/templates/src/app/web/system/pages/roles/index.tsx +4 -8
- package/templates/src/app/web/system/pages/roles/role-label.ts +26 -0
- package/templates/src/app/web/system/pages/solutions/index.tsx +110 -0
- package/templates/src/app/web/system/pages/solutions/market-entry.ts +46 -0
- package/templates/src/app/web/system/pages/users/index.tsx +12 -2
- package/templates/src/app/web/system/pages/users/match-keyword.ts +19 -0
- package/templates/src/app/web/system/pages/users/roles.tsx +43 -14
- package/templates/src/app/web/system/routes.ts +18 -1
- package/templates/src/{app/api/_platform/market → oj-market/api}/api.ts +475 -592
- package/templates/src/oj-market/api/backfill.ts +221 -0
- package/templates/src/{app/api/_platform/market → oj-market/api}/bootstrap.ts +2 -2
- package/templates/src/oj-market/api/cdn/api.ts +312 -0
- package/templates/src/oj-market/api/cdn/shared.ts +104 -0
- package/templates/src/oj-market/api/cdn/signed-auth.ts +75 -0
- package/templates/src/oj-market/api/manifest.yaml +8 -0
- package/templates/src/oj-market/solution.yaml +21 -0
- package/templates/src/oj-market/web/__client/api.schemas.ts +167 -0
- package/templates/src/oj-market/web/__client/index.ts +509 -0
- package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +6 -6
- package/templates/src/oj-market/web/api/client.ts +6 -6
- package/templates/src/oj-market/web/api/defaultClient.ts +31 -25
- package/templates/src/oj-market/web/entry.ts +5 -3
- package/templates/src/oj-market/web/hooks/useMarket.ts +6 -6
- package/templates/src/oj-market/web/pages/MarketApp.tsx +20 -3
- package/templates/src/oj-market/web/publish/locales/en-US.json +1 -1
- package/templates/src/oj-market/web/publish/locales/zh-CN.json +1 -1
- package/templates/src/oj-market/web/publish/pages/history.tsx +2 -11
- package/templates/src/oj-market/web/routes.ts +12 -0
- package/templates/src/oj-market/web/types.ts +14 -8
- package/templates/tests/lifecycle/anonymous-wiring.spec.ts +65 -0
- package/templates/tests/lifecycle/backfill.spec.ts +392 -0
- package/templates/tests/lifecycle/bootstrap.spec.ts +1 -1
- package/templates/tests/lifecycle/bundle-converge.spec.ts +264 -0
- package/templates/tests/lifecycle/cdn-upload.spec.ts +661 -0
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +25 -0
- package/templates/tests/lifecycle/exec-grant.spec.ts +38 -0
- package/templates/tests/lifecycle/installer.spec.ts +36 -0
- package/templates/tests/lifecycle/market-api.spec.ts +448 -803
- package/templates/tests/lifecycle/preflight.spec.ts +18 -0
- package/templates/tests/lifecycle/trust.spec.ts +2 -2
- package/templates/tests/lifecycle/uninstall.spec.ts +36 -2
- package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
- package/templates/tests/solutions-entry.spec.ts +55 -0
- package/templates/tsconfig.paths.json +1 -0
- package/templates/web.config.ts +6 -6
- package/vendor/host-versions.json +3 -3
- package/bin/oj +0 -4
- package/src/market/__tests__/build-index.test.ts +0 -641
- package/src/market/__tests__/sharding.test.ts +0 -90
- package/src/market/__tests__/yank-ledger.test.ts +0 -60
- package/src/market/build-index.ts +0 -526
- package/src/market/index-builder.ts +0 -149
- package/src/market/sharding.ts +0 -94
- package/src/market/yank-ledger.ts +0 -91
- package/templates/src/app/api/_platform/_lifecycle/source-seq.ts +0 -88
- package/templates/src/app/api/_platform/market/index-source.ts +0 -112
- package/templates/tests/lifecycle/source-seq.spec.ts +0 -55
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// BDD:编排端点 + 市场索引(P1-12,R9/ISP)。
|
|
2
|
-
// 四端点同挂
|
|
3
|
-
// POST /api/
|
|
4
|
-
// GET /api/
|
|
5
|
-
// GET /api/
|
|
6
|
-
// GET /api/
|
|
2
|
+
// 四端点同挂 oj-market 模块 api 根(v4 起自 _platform/market 迁入,manifest deps 声明 _platform):
|
|
3
|
+
// POST /api/oj-market/install superadmin——runInstall 编排(端点不重复编排,P1-10 状态机负责推进)
|
|
4
|
+
// GET /api/oj-market/task?id= superadmin——任务轮询消费面(§10.2;内容写侧已脱敏,读侧无秘密可泄)
|
|
5
|
+
// GET /api/oj-market/meta 登录即可——{apiVersion}(peerPlatform 预检)
|
|
6
|
+
// GET /api/oj-market/index superadmin——DB 目录分页(market_release 聚合)+ 本地 state 对比(v4 §四,索引机制退役)
|
|
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 同约定) */
|
|
@@ -11,7 +11,7 @@ import type { FsFace } from "../../src/app/api/_platform/_lifecycle/atomic";
|
|
|
11
11
|
import type { FetchFace } from "../../src/app/api/_platform/_lifecycle/steps/download";
|
|
12
12
|
import type { ExecFace } from "../../src/app/api/_platform/_lifecycle/steps/extract";
|
|
13
13
|
import type { ReloadFace } from "../../src/app/api/_platform/_lifecycle/steps/reload";
|
|
14
|
-
import type { MarketEnv } from "../../src/
|
|
14
|
+
import type { MarketEnv } from "../../src/oj-market/api/api";
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
16
|
import { readFileSync } from "node:fs";
|
|
17
17
|
import * as fsp from "node:fs/promises";
|
|
@@ -21,15 +21,14 @@ import { fileURLToPath } from "node:url";
|
|
|
21
21
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
22
22
|
import { parse } from "yaml";
|
|
23
23
|
import { defaultTestKey, nodeVerifyEd25519, signedArchiveFor } from "../../../src/lifecycle/__tests__/helpers";
|
|
24
|
-
import {
|
|
24
|
+
import { generateKeyPair } from "../../../src/lifecycle/keygen";
|
|
25
|
+
import { signCanonical } from "../../../src/lifecycle/sign";
|
|
25
26
|
import { readLock, writeLock } from "../../src/app/api/_platform/_lifecycle/lock";
|
|
26
|
-
import { compareVersions } from "../../src/app/api/_platform/_lifecycle/semver";
|
|
27
27
|
import { createStateStore } from "../../src/app/api/_platform/_lifecycle/state";
|
|
28
28
|
import { getTask, writeTask } from "../../src/app/api/_platform/_lifecycle/tasks";
|
|
29
29
|
import { createTrustStore } from "../../src/app/api/_platform/_lifecycle/trust";
|
|
30
30
|
import { invalidateAllUsers } from "../../src/app/api/_platform/_shared/service/infra/access";
|
|
31
|
-
import { createMarketApi, marketEnv, parseVersionsJson, versionsJsonPathOf } from "../../src/
|
|
32
|
-
import { localIndexSource } from "../../src/app/api/_platform/market/index-source";
|
|
31
|
+
import { createMarketApi, marketEnv, parseVersionsJson, versionsJsonPathOf } from "../../src/oj-market/api/api";
|
|
33
32
|
import { installDb } from "../fixture";
|
|
34
33
|
|
|
35
34
|
function hexToBytes(hex: string): Uint8Array {
|
|
@@ -85,45 +84,6 @@ function manifestFixture(version = "0.1.0"): any {
|
|
|
85
84
|
};
|
|
86
85
|
}
|
|
87
86
|
|
|
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,
|
|
101
|
-
generated_at: "2026-10-17T00:00:00Z",
|
|
102
|
-
cdn: CDN,
|
|
103
|
-
// 片号 → 代次钉值(REG-CS1③:分片签名 seq 必须等于钉值;byte 相同的分片可跨代复用)
|
|
104
|
-
shards: { 1: 1 },
|
|
105
|
-
// 包名 → 分片号(无版本明细;版本明细在分片里)
|
|
106
|
-
solutions: [
|
|
107
|
-
{ name: "demo-solution", shards: [1] },
|
|
108
|
-
{ name: "wiki", shards: [1] },
|
|
109
|
-
],
|
|
110
|
-
});
|
|
111
|
-
/** 最新版详细描述(C2-05;喂给 index 聚合断言,验证卡片能读到方案「提供什么」) */
|
|
112
|
-
const LATEST_DESCRIPTION = "演示方案:提供示例 CRUD 与菜单路由,覆盖安装、升级、回滚与历史留痕全链路。";
|
|
113
|
-
/** 分片正文(纯内容无 meta:版本条目即 Form B manifest——原 manifests.yaml 聚合清单并入分片) */
|
|
114
|
-
const SHARD_1 = JSON.stringify({
|
|
115
|
-
solutions: [
|
|
116
|
-
{ name: "demo-solution", latest: "0.2.0", versions: [{ ...manifestFixture("0.2.0"), description: LATEST_DESCRIPTION }, manifestFixture("0.1.0")] },
|
|
117
|
-
{ name: "wiki", latest: "1.3.0", versions: [{ name: "wiki", version: "1.3.0", archive: { file: "wiki-1.3.0.tgz", sha256: "x", size: 1 } }] },
|
|
118
|
-
],
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
/** 基线代次 */
|
|
122
|
-
const SEQ = 1;
|
|
123
|
-
/** 索引签名(REG-CS1:签名覆盖 (kind, seq)——换 kind 或换代都验不过) */
|
|
124
|
-
const CATALOG_SIG = JSON.stringify(signIndex("catalog", SEQ, CATALOG, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
125
|
-
const SHARD_1_SIG = JSON.stringify(signIndex("shard", 1, SHARD_1, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
126
|
-
|
|
127
87
|
let root: string;
|
|
128
88
|
let sharedRoot: string;
|
|
129
89
|
let staging: string;
|
|
@@ -142,20 +102,7 @@ const fetchInits: unknown[] = [];
|
|
|
142
102
|
const cdnFetch = ((url: string, init?: unknown) => {
|
|
143
103
|
fetchCalls.push(url);
|
|
144
104
|
fetchInits.push(init);
|
|
145
|
-
//
|
|
146
|
-
// .sig 判定先行:`catalog.yaml.sig` 也以 `.yaml` 结尾,顺序反了会把签名当正文。
|
|
147
|
-
if (url.endsWith("catalog.yaml.sig"))
|
|
148
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(CATALOG_SIG).buffer as ArrayBuffer };
|
|
149
|
-
if (url.endsWith("shards/1.yaml.sig"))
|
|
150
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(SHARD_1_SIG).buffer as ArrayBuffer };
|
|
151
|
-
const text = url.endsWith("catalog.yaml")
|
|
152
|
-
? CATALOG
|
|
153
|
-
: url.endsWith("shards/1.yaml")
|
|
154
|
-
? SHARD_1
|
|
155
|
-
: null;
|
|
156
|
-
if (text !== null) {
|
|
157
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
158
|
-
}
|
|
105
|
+
// v4 §四:安装解析直读 DB 目录,fetch 只剩「包字节 + smoke 探针」两类出站
|
|
159
106
|
if (url.endsWith(".tgz")) {
|
|
160
107
|
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
161
108
|
}
|
|
@@ -271,6 +218,7 @@ beforeEach(async () => {
|
|
|
271
218
|
fail: (status: number, msg: string) => {
|
|
272
219
|
last = { status, msg };
|
|
273
220
|
},
|
|
221
|
+
header: () => {},
|
|
274
222
|
};
|
|
275
223
|
last = null;
|
|
276
224
|
fetchCalls.length = 0;
|
|
@@ -287,6 +235,23 @@ afterEach(async () => {
|
|
|
287
235
|
await fsp.rm(root, { recursive: true, force: true });
|
|
288
236
|
});
|
|
289
237
|
|
|
238
|
+
/**
|
|
239
|
+
* 直插 market_release 行(v4 §四 DB 目录:目录聚合/安装解析/releases 共同数据源;上传链路见 cdn-upload.spec)。
|
|
240
|
+
* signed(缺省 true)按 manifestFixture 同源签名落 sig 三列——install 用例 verify 段六码分流需要;
|
|
241
|
+
* manifest 缺省 null(目录聚合类用例只数行数;卡片字段用例显式传 manifest)。
|
|
242
|
+
*/
|
|
243
|
+
function seedRelease(product: string, version: string, opts: { key_id?: string | null, published_at?: number, yanked?: number, manifest?: unknown, signed?: boolean } = {}) {
|
|
244
|
+
const at = opts.published_at ?? 1000;
|
|
245
|
+
const sig = opts.signed === false
|
|
246
|
+
? { alg: null, value: null, pk: null, reg: null }
|
|
247
|
+
: (() => {
|
|
248
|
+
const s = signedArchiveFor(ARCHIVE, product, version, { keyPair: TEST_KEY }).sig;
|
|
249
|
+
return { alg: String(s.alg), value: String(s.value), pk: String(s.public_key), reg: String(s.registry_id) };
|
|
250
|
+
})();
|
|
251
|
+
dbo.prepare("INSERT INTO market_release (product,version,key_id,sha256,size,published_at,published_at_source,yanked,first_seen_at,sig_alg,sig_value,sig_public_key,sig_registry_id,manifest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
|
|
252
|
+
.run(product, version, "key_id" in opts ? opts.key_id : "test.publisher", SHA256, ARCHIVE.byteLength, at, "upload", opts.yanked ?? 0, at, sig.alg, sig.value, sig.pk, sig.reg, opts.manifest === undefined ? null : JSON.stringify(opts.manifest));
|
|
253
|
+
}
|
|
254
|
+
|
|
290
255
|
describe("权限门(R9:市场编排 superadmin 独占,meta 登录即可)", () => {
|
|
291
256
|
it("非 superadmin 访问 install/task/index 一律 403(拒权不泄露存在性)", async () => {
|
|
292
257
|
g.http = { user: { id: 2 } };
|
|
@@ -322,30 +287,321 @@ describe("权限门(R9:市场编排 superadmin 独占,meta 登录即可)
|
|
|
322
287
|
});
|
|
323
288
|
});
|
|
324
289
|
|
|
325
|
-
describe("
|
|
326
|
-
it("
|
|
290
|
+
describe("0003 迁移(market_release 扩列演化,v4 §一:表演化非新建)", () => {
|
|
291
|
+
it("sig_alg/sig_value/sig_public_key/manifest 四列就位,既有列与表不破坏(releases 端点照常读)", () => {
|
|
292
|
+
const cols = (dbo.prepare("PRAGMA table_info(market_release)").all() as Array<{ name: string }>).map(c => c.name);
|
|
293
|
+
// 新增四列(DB 目录化:签名三字段 + 包内嵌 manifest 抽取物)
|
|
294
|
+
for (const c of ["sig_alg", "sig_value", "sig_public_key", "manifest"])
|
|
295
|
+
expect(cols).toContain(c);
|
|
296
|
+
// 既有九列不破坏(0002 形状原样保留,列名沿用 product 不改 name)
|
|
297
|
+
for (const c of ["id", "product", "version", "key_id", "sha256", "size", "published_at", "published_at_source", "yanked", "first_seen_at"])
|
|
298
|
+
expect(cols).toContain(c);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("GET catalog(v4 §三:匿名分页目录 JSON,DB 直读合成单协议 shape)", () => {
|
|
303
|
+
/** 直插 market_release 行(目录聚合数据源;上传链路见 cdn-upload.spec) */
|
|
304
|
+
function seedRelease(product: string, version: string, opts: { key_id?: string | null, published_at?: number, yanked?: number, manifest?: unknown } = {}) {
|
|
305
|
+
const at = opts.published_at ?? 1000;
|
|
306
|
+
dbo.prepare("INSERT INTO market_release (product,version,key_id,sha256,size,published_at,published_at_source,yanked,first_seen_at,manifest) VALUES (?,?,?,?,?,?,?,?,?,?)")
|
|
307
|
+
.run(product, version, "key_id" in opts ? opts.key_id : "test.publisher", SHA256, ARCHIVE.byteLength, at, "upload", opts.yanked ?? 0, at, opts.manifest === undefined ? null : JSON.stringify(opts.manifest));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
it("25 包 ?page=2&size=10 → 第 11–20 条 + total=25 + page/size 回显 + api_version + items 形状", async () => {
|
|
311
|
+
for (let i = 1; i <= 25; i++)
|
|
312
|
+
seedRelease(`pkg-${String(i).padStart(2, "0")}`, "0.1.0", { published_at: 1000 + i, manifest: { name: `pkg-${String(i).padStart(2, "0")}`, version: "0.1.0", summary: `方案 ${i}` } });
|
|
313
|
+
const api = createMarketApi(makeEnv());
|
|
314
|
+
g.http = { query: { page: "2", size: "10" } };
|
|
315
|
+
await api.catalog.get();
|
|
316
|
+
expect(last?.status).toBe(200);
|
|
317
|
+
const d = last?.data as { api_version: string, page: number, size: number, total: number, truncated: boolean, items: Array<Record<string, unknown>> };
|
|
318
|
+
expect(d.api_version).toBe("v1");
|
|
319
|
+
expect(d.page).toBe(2);
|
|
320
|
+
expect(d.size).toBe(10);
|
|
321
|
+
expect(d.total).toBe(25);
|
|
322
|
+
expect(d.items).toHaveLength(10);
|
|
323
|
+
// 排序 published_at desc → 第 11–20 条 = pkg-15 … pkg-06(published_at 1025..1016 倒序)
|
|
324
|
+
expect((d.items[0] as { product: string }).product).toBe("pkg-15");
|
|
325
|
+
expect((d.items[9] as { product: string }).product).toBe("pkg-06");
|
|
326
|
+
// items 形状:product/latest/publisher(key_id)/versions_count/summary
|
|
327
|
+
expect(d.items[0]).toMatchObject({ product: "pkg-15", latest: "0.1.0", publisher: "test.publisher", versions_count: 1, summary: "方案 15" });
|
|
328
|
+
// 中间页截断标记
|
|
329
|
+
expect(d.truncated).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("响应顶层带 registry_id(签名推送 canonical 首段 = 站点身份,CLI 发现面);未配置 → null", async () => {
|
|
333
|
+
seedRelease("pkg-a", "0.1.0");
|
|
334
|
+
// 显式注入站点身份(生产 = config.yaml vars.registry_id,经 readSiteVar 同源回落)
|
|
335
|
+
g.http = { query: {} };
|
|
336
|
+
await createMarketApi(makeEnv({ registryId: "test.registry" })).catalog.get();
|
|
337
|
+
expect(last?.status).toBe(200);
|
|
338
|
+
expect((last?.data as { registry_id: string }).registry_id).toBe("test.registry");
|
|
339
|
+
// 未配置(注入缺省 + 无 config vars)→ null:CLI 侧据此给「站点未配置 registry_id」人话
|
|
340
|
+
await createMarketApi(makeEnv({ registryId: undefined })).catalog.get();
|
|
341
|
+
expect((last?.data as { registry_id: unknown }).registry_id).toBeNull();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("非法 page/size 收敛不报错(pageArgs 口径数据表);末页 truncated=false", async () => {
|
|
345
|
+
seedRelease("pkg-a", "0.1.0");
|
|
346
|
+
const api = createMarketApi(makeEnv());
|
|
347
|
+
for (const [page, size, expectPage, expectSize] of [["0", "0", 1, 20], ["-1", "abc", 1, 20], ["abc", "500", 1, 200], ["2.9", "10", 2, 10]] as const) {
|
|
348
|
+
g.http = { query: { page, size } };
|
|
349
|
+
await api.catalog.get();
|
|
350
|
+
expect(last?.status).toBe(200);
|
|
351
|
+
const d = last?.data as { page: number, size: number };
|
|
352
|
+
expect(d.page).toBe(expectPage);
|
|
353
|
+
expect(d.size).toBe(expectSize);
|
|
354
|
+
}
|
|
355
|
+
g.http = { query: { page: "1", size: "20" } };
|
|
356
|
+
await api.catalog.get();
|
|
357
|
+
expect((last?.data as { truncated: boolean }).truncated).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("排序稳定:同 published_at 按 product 升序(防翻页重复/丢条目)", async () => {
|
|
361
|
+
seedRelease("pkg-b", "0.1.0", { published_at: 5000 });
|
|
362
|
+
seedRelease("pkg-a", "0.1.0", { published_at: 5000 });
|
|
363
|
+
seedRelease("pkg-c", "0.1.0", { published_at: 5000 });
|
|
364
|
+
const api = createMarketApi(makeEnv());
|
|
365
|
+
g.http = { query: { size: "2", page: "1" } };
|
|
366
|
+
await api.catalog.get();
|
|
367
|
+
expect(((last?.data as { items: Array<{ product: string }> }).items).map(i => i.product)).toEqual(["pkg-a", "pkg-b"]);
|
|
368
|
+
g.http = { query: { size: "2", page: "2" } };
|
|
369
|
+
await api.catalog.get();
|
|
370
|
+
expect(((last?.data as { items: Array<{ product: string }> }).items).map(i => i.product)).toEqual(["pkg-c"]);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("清单不含 yanked 行:yanked 版本不计入;全 yanked 的 product 整项消失", async () => {
|
|
374
|
+
seedRelease("live-pkg", "0.1.0");
|
|
375
|
+
seedRelease("live-pkg", "0.2.0", { yanked: 1 });
|
|
376
|
+
seedRelease("gone-pkg", "0.1.0", { yanked: 1 });
|
|
377
|
+
const api = createMarketApi(makeEnv());
|
|
378
|
+
g.http = { query: {} };
|
|
379
|
+
await api.catalog.get();
|
|
380
|
+
const items = (last?.data as { items: Array<{ product: string, latest: string, versions_count: number }> }).items;
|
|
381
|
+
expect(items.map(i => i.product)).toEqual(["live-pkg"]);
|
|
382
|
+
expect(items[0]).toMatchObject({ latest: "0.1.0", versions_count: 1 });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("latest = 未 yanked 的 semver max(非存储列,API 计算);summary 取 latest 版 manifest", async () => {
|
|
386
|
+
seedRelease("sem-pkg", "0.10.0", { published_at: 100, manifest: { summary: "旧" } });
|
|
387
|
+
seedRelease("sem-pkg", "0.9.0", { published_at: 300, manifest: { summary: "新" } });
|
|
388
|
+
seedRelease("no-manifest-pkg", "1.0.0", { key_id: null });
|
|
389
|
+
const api = createMarketApi(makeEnv());
|
|
390
|
+
g.http = { query: {} };
|
|
391
|
+
await api.catalog.get();
|
|
392
|
+
const items = (last?.data as { items: Array<Record<string, unknown>> }).items;
|
|
393
|
+
const sem = items.find(i => i.product === "sem-pkg")!;
|
|
394
|
+
expect(sem.latest).toBe("0.10.0"); // semver 序非字典序
|
|
395
|
+
expect(sem.summary).toBe("旧"); // latest 0.10.0 的 manifest
|
|
396
|
+
expect(sem.publisher).toBe("test.publisher");
|
|
397
|
+
// 遗留行(manifest NULL、key_id NULL):summary 缺省、publisher null
|
|
398
|
+
const legacy = items.find(i => i.product === "no-manifest-pkg")!;
|
|
399
|
+
expect(legacy.summary).toBeUndefined();
|
|
400
|
+
expect(legacy.publisher).toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("匿名可读(无 user 也 200)+ Cache-Control: no-store 断言", async () => {
|
|
404
|
+
seedRelease("anon-pkg", "0.1.0");
|
|
405
|
+
const api = createMarketApi(makeEnv());
|
|
406
|
+
const headers: Array<[string, string]> = [];
|
|
407
|
+
g.json = {
|
|
408
|
+
ok: (data: unknown) => {
|
|
409
|
+
last = { status: 200, data };
|
|
410
|
+
},
|
|
411
|
+
fail: (status: number, msg: string) => {
|
|
412
|
+
last = { status, msg };
|
|
413
|
+
},
|
|
414
|
+
header: (k: string, v: string) => {
|
|
415
|
+
headers.push([k, v]);
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
g.http = { query: {} };
|
|
419
|
+
await api.catalog.get();
|
|
420
|
+
expect(last?.status).toBe(200);
|
|
421
|
+
expect(headers).toContainEqual(["Cache-Control", "no-store"]);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("GET catalog/detail(v4 §三:详情保留 yanked 条目,安装解析数据源)", () => {
|
|
426
|
+
function seedRelease(product: string, version: string, opts: { key_id?: string, yanked?: number, published_at?: number, manifest?: unknown } = {}) {
|
|
427
|
+
const at = opts.published_at ?? 1000;
|
|
428
|
+
dbo.prepare("INSERT INTO market_release (product,version,key_id,sha256,size,published_at,published_at_source,yanked,first_seen_at,manifest) VALUES (?,?,?,?,?,?,?,?,?,?)")
|
|
429
|
+
.run(product, version, opts.key_id ?? "test.publisher", SHA256, ARCHIVE.byteLength, at, "upload", opts.yanked ?? 0, at, opts.manifest === undefined ? null : JSON.stringify(opts.manifest));
|
|
430
|
+
}
|
|
431
|
+
const MANIFEST_010 = { name: "demo-solution", version: "0.1.0", summary: "演示", archive: { file: "demo-solution-0.1.0.tgz" } };
|
|
432
|
+
|
|
433
|
+
it("versions 全量含 yanked:true 条目、semver 降序;archive.file 正名 + signature 落位", async () => {
|
|
434
|
+
seedRelease("demo-solution", "0.1.0", { manifest: MANIFEST_010 });
|
|
435
|
+
seedRelease("demo-solution", "0.2.0", { yanked: 1, published_at: 2000, manifest: { name: "demo-solution", version: "0.2.0", archive: { file: "wrong-name.tgz" } } });
|
|
436
|
+
const api = createMarketApi(makeEnv());
|
|
437
|
+
g.http = { query: { name: "demo-solution" } };
|
|
438
|
+
await api.catalog.detail.get();
|
|
439
|
+
expect(last?.status).toBe(200);
|
|
440
|
+
const d = last?.data as { product: string, latest: string, versions: Array<Record<string, unknown>> };
|
|
441
|
+
expect(d.product).toBe("demo-solution");
|
|
442
|
+
expect(d.latest).toBe("0.1.0"); // yanked 不计入 latest
|
|
443
|
+
expect(d.versions.map(v => v.version)).toEqual(["0.2.0", "0.1.0"]); // semver 降序
|
|
444
|
+
expect(d.versions[0]).toMatchObject({ version: "0.2.0", yanked: true });
|
|
445
|
+
expect(d.versions[1]).toMatchObject({ version: "0.1.0", yanked: false, summary: "演示" });
|
|
446
|
+
// archive.file 由服务端组表正名(存量 manifest 内嵌 file 不可信,逐字 <name>-<version>.tgz)
|
|
447
|
+
for (const v of d.versions)
|
|
448
|
+
expect((v.archive as { file: string }).file).toBe(`demo-solution-${v.version}.tgz`);
|
|
449
|
+
expect((d.versions[0].archive as Record<string, unknown>).sha256).toBe(SHA256);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("未知 name → 404;?version= 过滤;全 yanked 时 latest=null", async () => {
|
|
453
|
+
seedRelease("demo-solution", "0.1.0");
|
|
454
|
+
seedRelease("yanked-all", "0.1.0", { yanked: 1 });
|
|
455
|
+
const api = createMarketApi(makeEnv());
|
|
456
|
+
g.http = { query: { name: "nope" } };
|
|
457
|
+
await api.catalog.detail.get();
|
|
458
|
+
expect(last?.status).toBe(404);
|
|
459
|
+
g.http = { query: { name: "demo-solution", version: "0.1.0" } };
|
|
460
|
+
await api.catalog.detail.get();
|
|
461
|
+
expect((last?.data as { versions: unknown[] }).versions).toHaveLength(1);
|
|
462
|
+
g.http = { query: { name: "demo-solution", version: "9.9.9" } };
|
|
463
|
+
await api.catalog.detail.get();
|
|
464
|
+
expect((last?.data as { versions: unknown[] }).versions).toHaveLength(0);
|
|
465
|
+
g.http = { query: { name: "yanked-all" } };
|
|
466
|
+
await api.catalog.detail.get();
|
|
467
|
+
expect((last?.data as { latest: string | null }).latest).toBeNull();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("匿名可读 + 错误文案卫生(404 message 不含路径/SQL/内部拓扑)", async () => {
|
|
471
|
+
const api = createMarketApi(makeEnv());
|
|
472
|
+
g.http = { query: { name: "nope" } };
|
|
473
|
+
await api.catalog.detail.get();
|
|
474
|
+
expect(last?.status).toBe(404);
|
|
475
|
+
expect(String(last?.msg)).not.toMatch(/\/|SELECT|sqlite|cdn\//i);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("POST /market/yank(v4 §三:签名 yank,清单摘除/详情保留)", () => {
|
|
480
|
+
const REGISTRY = "test.registry";
|
|
481
|
+
function seedRelease(product: string, version: string, opts: { key_id?: string | null, yanked?: number, published_at?: number } = {}) {
|
|
482
|
+
const at = opts.published_at ?? 1000;
|
|
483
|
+
dbo.prepare("INSERT INTO market_release (product,version,key_id,sha256,size,published_at,published_at_source,yanked,first_seen_at) VALUES (?,?,?,?,?,?,?,?,?)")
|
|
484
|
+
.run(product, version, "key_id" in opts ? opts.key_id : "test.publisher", SHA256, ARCHIVE.byteLength, at, "upload", opts.yanked ?? 0, at);
|
|
485
|
+
}
|
|
486
|
+
/** yank 签名面:canonical `market-yank:<registry>:<product>:<version>`(服务端重算口径同上传) */
|
|
487
|
+
function yankSignature(product: string, version: string, key: typeof TEST_KEY = TEST_KEY, keyId = "test.publisher") {
|
|
488
|
+
return { "x-ojm-key-id": keyId, "x-ojm-signature": signCanonical(key.privateKeyPem, `market-yank:${REGISTRY}:${product}:${version}`) };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
it("签名 + key 匹配行 key_id → 200:行 yanked=1、catalog 清单即摘除、detail 标 yanked:true", async () => {
|
|
492
|
+
seedRelease("demo-solution", "0.1.0");
|
|
493
|
+
const api = createMarketApi(makeEnv({ registryId: REGISTRY }));
|
|
494
|
+
g.http = { user: { id: 1 }, body: { product: "demo-solution", version: "0.1.0", by: "rel-ops", reason: "严重缺陷" }, headers: yankSignature("demo-solution", "0.1.0") };
|
|
495
|
+
await api.yank.post();
|
|
496
|
+
expect(last?.status).toBe(200);
|
|
497
|
+
expect(last?.data).toMatchObject({ ok: true, product: "demo-solution", version: "0.1.0", yanked: true });
|
|
498
|
+
expect(dbo.prepare("SELECT yanked FROM market_release WHERE product=? AND version=?").get("demo-solution", "0.1.0").yanked).toBe(1);
|
|
499
|
+
// 清单即摘除(匿名面直读)
|
|
500
|
+
g.http = { query: {} };
|
|
501
|
+
await api.catalog.get();
|
|
502
|
+
expect((last?.data as { items: unknown[] }).items).toHaveLength(0);
|
|
503
|
+
// 详情保留 + yanked 标记
|
|
504
|
+
g.http = { query: { name: "demo-solution" } };
|
|
505
|
+
await api.catalog.detail.get();
|
|
506
|
+
const d = last?.data as { latest: string | null, versions: Array<{ yanked: boolean }> };
|
|
507
|
+
expect(d.latest).toBeNull();
|
|
508
|
+
expect(d.versions[0]!.yanked).toBe(true);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("审计:by/reason 落 market_history(action=yank,operator=key_id)", async () => {
|
|
512
|
+
seedRelease("demo-solution", "0.1.0");
|
|
513
|
+
const api = createMarketApi(makeEnv({ registryId: REGISTRY }));
|
|
514
|
+
g.http = { user: { id: 1 }, body: { product: "demo-solution", version: "0.1.0", by: "rel-ops", reason: "严重缺陷" }, headers: yankSignature("demo-solution", "0.1.0") };
|
|
515
|
+
await api.yank.post();
|
|
516
|
+
const rows = dbo.prepare("SELECT * FROM market_history WHERE action='yank'").all();
|
|
517
|
+
expect(rows).toHaveLength(1);
|
|
518
|
+
expect(rows[0]).toMatchObject({ solution: "demo-solution", version: "0.1.0", status: "success", operator: "test.publisher" });
|
|
519
|
+
expect(String(rows[0].message)).toContain("rel-ops");
|
|
520
|
+
expect(String(rows[0].message)).toContain("严重缺陷");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("数据表:未信任 key 403 / 非该行发布者 403 / 未知版本 404 / 重复幂等 200", async () => {
|
|
524
|
+
seedRelease("demo-solution", "0.1.0");
|
|
525
|
+
seedRelease("other-pkg", "0.1.0", { key_id: "other.publisher" });
|
|
526
|
+
const foreign = generateKeyPair();
|
|
527
|
+
const api = createMarketApi(makeEnv({ registryId: REGISTRY }));
|
|
528
|
+
// 未信任 key
|
|
529
|
+
g.http = { body: { product: "demo-solution", version: "0.1.0" }, headers: { ...yankSignature("demo-solution", "0.1.0", foreign, "foreign.publisher") } };
|
|
530
|
+
await api.yank.post();
|
|
531
|
+
expect(last?.status).toBe(403);
|
|
532
|
+
// 受信但非该行发布者(other.publisher 未入信任库,先入再测——否则落 403 信任面)
|
|
533
|
+
await createTrustStore(join(sharedRoot, ".solutions", "trusted-keys.yaml"), { audit: () => {} }).add("other.publisher", foreign.publicKeyHex);
|
|
534
|
+
g.http = { body: { product: "demo-solution", version: "0.1.0" }, headers: { ...yankSignature("demo-solution", "0.1.0", foreign, "other.publisher") } };
|
|
535
|
+
await api.yank.post();
|
|
536
|
+
expect(last?.status).toBe(403);
|
|
537
|
+
expect(String(last?.msg)).toContain("发布者");
|
|
538
|
+
// 未知版本 404
|
|
539
|
+
g.http = { body: { product: "demo-solution", version: "9.9.9" }, headers: yankSignature("demo-solution", "9.9.9") };
|
|
540
|
+
await api.yank.post();
|
|
541
|
+
expect(last?.status).toBe(404);
|
|
542
|
+
// 重复 yank 幂等 200
|
|
543
|
+
g.http = { body: { product: "demo-solution", version: "0.1.0" }, headers: yankSignature("demo-solution", "0.1.0") };
|
|
544
|
+
await api.yank.post();
|
|
545
|
+
expect(last?.status).toBe(200);
|
|
546
|
+
g.http = { body: { product: "demo-solution", version: "0.1.0" }, headers: yankSignature("demo-solution", "0.1.0") };
|
|
547
|
+
await api.yank.post();
|
|
548
|
+
expect(last?.status).toBe(200);
|
|
549
|
+
expect((last?.data as { idempotent: boolean }).idempotent).toBe(true);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("伪造签名(canonical 不符)→ 401", async () => {
|
|
553
|
+
seedRelease("demo-solution", "0.1.0");
|
|
554
|
+
const api = createMarketApi(makeEnv({ registryId: REGISTRY }));
|
|
555
|
+
g.http = { body: { product: "demo-solution", version: "0.1.0" }, headers: yankSignature("demo-solution", "0.2.0") };
|
|
556
|
+
await api.yank.post();
|
|
557
|
+
expect(last?.status).toBe(401);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("unyank 恢复同模型:restore → 200、行 yanked=0、清单恢复;遗留行(key_id NULL)403 指向 superadmin 通道", async () => {
|
|
561
|
+
seedRelease("demo-solution", "0.1.0", { yanked: 1 });
|
|
562
|
+
seedRelease("legacy-pkg", "0.1.0", { key_id: null });
|
|
563
|
+
const api = createMarketApi(makeEnv({ registryId: REGISTRY }));
|
|
564
|
+
g.http = { body: { product: "demo-solution", version: "0.1.0", restore: true }, headers: yankSignature("demo-solution", "0.1.0") };
|
|
565
|
+
await api.yank.post();
|
|
566
|
+
expect(last?.status).toBe(200);
|
|
567
|
+
expect((last?.data as { yanked: boolean }).yanked).toBe(false);
|
|
568
|
+
expect(dbo.prepare("SELECT yanked FROM market_release WHERE product=? AND version=?").get("demo-solution", "0.1.0").yanked).toBe(0);
|
|
569
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM market_history WHERE action='unyank'").all()[0].c).toBe(1);
|
|
570
|
+
g.http = { query: {} };
|
|
571
|
+
await api.catalog.get();
|
|
572
|
+
expect((last?.data as { items: Array<{ product: string }> }).items.map(i => i.product)).toContain("demo-solution");
|
|
573
|
+
// 遗留行(无发布者)→ 403,superadmin 管理通道兜底
|
|
574
|
+
g.http = { body: { product: "legacy-pkg", version: "0.1.0" }, headers: yankSignature("legacy-pkg", "0.1.0") };
|
|
575
|
+
await api.yank.post();
|
|
576
|
+
expect(last?.status).toBe(403);
|
|
577
|
+
expect(String(last?.msg)).toContain("superadmin");
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe("POST install(cdn:DB 目录解析属市场侧,安装器不拼 URL;v4 §四)", () => {
|
|
582
|
+
it("指定版本:目录行解析 → runInstall 推进至 done,task 文件全 stage 就位且零索引出站", async () => {
|
|
583
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
584
|
+
seedRelease("demo-solution", "0.2.0", { published_at: 2000, manifest: manifestFixture("0.2.0") });
|
|
327
585
|
const api = createMarketApi(makeEnv());
|
|
328
586
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
329
587
|
await api.install.post();
|
|
330
588
|
expect(last?.status).toBe(200);
|
|
331
589
|
const taskId = (last?.data as { task_id?: unknown })?.task_id;
|
|
332
590
|
expect(typeof taskId).toBe("string");
|
|
333
|
-
//
|
|
334
|
-
expect(fetchCalls[0]).toBe(`${CDN}catalog.yaml`);
|
|
335
|
-
expect(fetchCalls).toContain(`${CDN}shards/1.yaml`);
|
|
591
|
+
// 解析直读 DB:出站只有分发包字节(绝不拉 catalog.yaml/shards)
|
|
336
592
|
expect(fetchCalls).toContain(`${CDN}demo-solution-0.1.0.tgz`);
|
|
337
|
-
|
|
338
|
-
// stale-while-revalidate=60 + ETag/If-None-Match 条件请求承载(Q8)
|
|
339
|
-
expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["cache-control"] === undefined)).toBe(true);
|
|
593
|
+
expect(fetchCalls.some(u => u.endsWith(".yaml") || u.endsWith(".yaml.sig"))).toBe(false);
|
|
340
594
|
// 后台推进:runInstall 状态机逐 stage 落盘,终态 done + 全 stage 明细
|
|
341
595
|
const rec = await getTask(taskDir, taskId as string);
|
|
342
596
|
expect(rec?.status).toBe("done");
|
|
343
|
-
expect(rec?.stages.map(
|
|
597
|
+
expect(rec?.stages.map(x => x.stage)).toEqual(STAGE_ORDER);
|
|
344
598
|
expect(rec?.version).toBe("0.1.0");
|
|
345
599
|
expect(rec?.appliedSeq).toBe(3);
|
|
346
600
|
});
|
|
347
601
|
|
|
348
|
-
it("version 缺省取 latest
|
|
602
|
+
it("version 缺省取 latest(未 yanked 的 semver max,API 计算)", async () => {
|
|
603
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
604
|
+
seedRelease("demo-solution", "0.2.0", { published_at: 2000, manifest: manifestFixture("0.2.0") });
|
|
349
605
|
const api = createMarketApi(makeEnv());
|
|
350
606
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
|
|
351
607
|
await api.install.post();
|
|
@@ -355,7 +611,7 @@ describe("POST install(cdn:Form B 索引解析属市场侧,安装器不拼
|
|
|
355
611
|
expect((await getTask(taskDir, taskId))?.version).toBe("0.2.0");
|
|
356
612
|
});
|
|
357
613
|
|
|
358
|
-
it("可读失败不崩:source 非法 400 / 未配置市场地址 503 /
|
|
614
|
+
it("可读失败不崩:source 非法 400 / 未配置市场地址 503 / 目录无此方案 404(含回填指引)/ 目录无此版本 404", async () => {
|
|
359
615
|
const api = createMarketApi(makeEnv());
|
|
360
616
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "git" } };
|
|
361
617
|
await api.install.post();
|
|
@@ -368,49 +624,20 @@ describe("POST install(cdn:Form B 索引解析属市场侧,安装器不拼
|
|
|
368
624
|
g.http = { user: { id: 1 }, body: { name: "nope", version: null, source: "cdn" } };
|
|
369
625
|
await api.install.post();
|
|
370
626
|
expect(last?.status).toBe(404);
|
|
627
|
+
expect(String(last?.msg)).toContain("回填");
|
|
628
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
629
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "9.9.9", source: "cdn" } };
|
|
630
|
+
await api.install.post();
|
|
631
|
+
expect(last?.status).toBe(404);
|
|
632
|
+
expect(String(last?.msg)).toContain("9.9.9");
|
|
371
633
|
});
|
|
372
634
|
});
|
|
373
635
|
|
|
374
|
-
describe("REG-CS4 yank
|
|
375
|
-
/** 带 yanked 标记的三级索引:0.2.0 被下线、0.1.0 正常;latest 已被源方改为 0.1.0 */
|
|
376
|
-
function yankedCdnFetch(): FetchFace {
|
|
377
|
-
const catalog = JSON.stringify({
|
|
378
|
-
seq: 1,
|
|
379
|
-
issued_at: ISSUED_AT,
|
|
380
|
-
expires_at: EXPIRES_AT,
|
|
381
|
-
shards: { 1: 1 },
|
|
382
|
-
solutions: [{ name: "demo-solution", shards: [1] }],
|
|
383
|
-
});
|
|
384
|
-
const shard = JSON.stringify({
|
|
385
|
-
solutions: [{
|
|
386
|
-
name: "demo-solution",
|
|
387
|
-
latest: "0.1.0",
|
|
388
|
-
versions: [
|
|
389
|
-
{ ...manifestFixture("0.2.0"), yanked: true, yanked_at: ISSUED_AT },
|
|
390
|
-
manifestFixture("0.1.0"),
|
|
391
|
-
],
|
|
392
|
-
}],
|
|
393
|
-
});
|
|
394
|
-
const catalogSig = JSON.stringify(signIndex("catalog", SEQ, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
395
|
-
const shardSig = JSON.stringify(signIndex("shard", 1, shard, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
396
|
-
return ((url: string) => {
|
|
397
|
-
const text = String(url).endsWith("catalog.yaml")
|
|
398
|
-
? catalog
|
|
399
|
-
: String(url).endsWith("shards/1.yaml") ? shard : null;
|
|
400
|
-
if (text !== null)
|
|
401
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
402
|
-
if (String(url).endsWith(".yaml.sig")) {
|
|
403
|
-
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
404
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
405
|
-
}
|
|
406
|
-
if (String(url).endsWith(".tgz"))
|
|
407
|
-
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
408
|
-
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
409
|
-
}) as FetchFace;
|
|
410
|
-
}
|
|
411
|
-
|
|
636
|
+
describe("REG-CS4 yank(安装侧精确版本拒装,DB 目录口径)", () => {
|
|
412
637
|
it("精确安装被下线版本 → 409 拒装(含「下线」人话),不触碰 .tgz", async () => {
|
|
413
|
-
|
|
638
|
+
seedRelease("demo-solution", "0.2.0", { yanked: 1, published_at: 2000, manifest: manifestFixture("0.2.0") });
|
|
639
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
640
|
+
const api = createMarketApi(makeEnv());
|
|
414
641
|
fetchCalls.length = 0;
|
|
415
642
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.2.0", source: "cdn" } };
|
|
416
643
|
await api.install.post();
|
|
@@ -419,14 +646,25 @@ describe("REG-CS4 yank(安装侧精确版本拒装)", () => {
|
|
|
419
646
|
expect(fetchCalls.some(u => u.endsWith(".tgz"))).toBe(false);
|
|
420
647
|
});
|
|
421
648
|
|
|
422
|
-
it("latest
|
|
423
|
-
|
|
649
|
+
it("latest 路径自动跳过 yanked(API 计算的 latest 只数未下线版本)→ 正常装 0.1.0", async () => {
|
|
650
|
+
seedRelease("demo-solution", "0.2.0", { yanked: 1, published_at: 2000, manifest: manifestFixture("0.2.0") });
|
|
651
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
652
|
+
const api = createMarketApi(makeEnv());
|
|
424
653
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
|
|
425
654
|
await api.install.post();
|
|
426
655
|
expect(last?.status).toBe(200);
|
|
427
656
|
const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
|
|
428
657
|
expect((await getTask(taskDir, taskId))?.version).toBe("0.1.0");
|
|
429
658
|
});
|
|
659
|
+
|
|
660
|
+
it("全部版本被下线 → 409 人话(latest 不可得)", async () => {
|
|
661
|
+
seedRelease("demo-solution", "0.1.0", { yanked: 1, manifest: manifestFixture("0.1.0") });
|
|
662
|
+
const api = createMarketApi(makeEnv());
|
|
663
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
|
|
664
|
+
await api.install.post();
|
|
665
|
+
expect(last?.status).toBe(409);
|
|
666
|
+
expect(String(last?.msg)).toContain("下线");
|
|
667
|
+
});
|
|
430
668
|
});
|
|
431
669
|
|
|
432
670
|
describe("相对基址(REG-CS6:/cdn 自供按配置的 canonical_origin 补全,绝不读请求 Host 头防 SSRF)", () => {
|
|
@@ -434,7 +672,8 @@ describe("相对基址(REG-CS6:/cdn 自供按配置的 canonical_origin 补
|
|
|
434
672
|
const FORGED = "169.254.169.254"; // 伪造 Host(云元数据 IP)——绝不可牵引出站
|
|
435
673
|
const relEnv = (overrides: Partial<MarketEnv> = {}) => makeEnv({ cdnBase: async () => REL, ...overrides });
|
|
436
674
|
|
|
437
|
-
it("自供回归:canonical_origin 配置 → 相对 /cdn URL 按配置 origin 补全绝对 URL(非 Host 头)", async () => {
|
|
675
|
+
it("自供回归:canonical_origin 配置 → 相对 /cdn 包字节 URL 按配置 origin 补全绝对 URL(非 Host 头)", async () => {
|
|
676
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
438
677
|
const api = createMarketApi(relEnv({ canonicalOrigin: "http://127.0.0.1:5174" }));
|
|
439
678
|
fetchCalls.length = 0;
|
|
440
679
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" }, headers: { host: FORGED } };
|
|
@@ -445,30 +684,8 @@ describe("相对基址(REG-CS6:/cdn 自供按配置的 canonical_origin 补
|
|
|
445
684
|
expect(fetchCalls.some(u => String(u).includes(FORGED))).toBe(false);
|
|
446
685
|
});
|
|
447
686
|
|
|
448
|
-
it("index:canonical_origin 配置 → 索引与分片拉取按配置 origin 补全(非 Host 头)", async () => {
|
|
449
|
-
const api = createMarketApi(relEnv({ canonicalOrigin: "http://127.0.0.1:5174" }));
|
|
450
|
-
fetchCalls.length = 0;
|
|
451
|
-
g.http = { user: { id: 1 }, headers: { host: FORGED } };
|
|
452
|
-
await api.index.get();
|
|
453
|
-
expect(last?.status).toBe(200);
|
|
454
|
-
expect(fetchCalls).toContain("http://127.0.0.1:5174/cdn/catalog.yaml");
|
|
455
|
-
expect(fetchCalls).toContain("http://127.0.0.1:5174/cdn/shards/1.yaml");
|
|
456
|
-
expect(fetchCalls.some(u => String(u).includes(FORGED))).toBe(false);
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
it("index:canonical_origin 缺失 + 相对基址 → 明确报错指向 vars.canonical_origin(不回落 Host)", async () => {
|
|
460
|
-
const api = createMarketApi(relEnv({ canonicalOrigin: undefined }));
|
|
461
|
-
fetchCalls.length = 0;
|
|
462
|
-
g.http = { user: { id: 1 }, headers: { host: FORGED } };
|
|
463
|
-
await api.index.get();
|
|
464
|
-
expect(last?.status).toBe(503);
|
|
465
|
-
expect(last?.msg).toContain("vars.canonical_origin");
|
|
466
|
-
// 未发起任何出站;伪造 Host 头绝不被采用
|
|
467
|
-
expect(fetchCalls.length).toBe(0);
|
|
468
|
-
expect(fetchCalls.some(u => String(u).includes(FORGED))).toBe(false);
|
|
469
|
-
});
|
|
470
|
-
|
|
471
687
|
it("install:canonical_origin 非法(ftp://)→ 明确报错指向 vars.canonical_origin", async () => {
|
|
688
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
472
689
|
const api = createMarketApi(relEnv({ canonicalOrigin: "ftp://nope" }));
|
|
473
690
|
fetchCalls.length = 0;
|
|
474
691
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" }, headers: { host: FORGED } };
|
|
@@ -480,6 +697,7 @@ describe("相对基址(REG-CS6:/cdn 自供按配置的 canonical_origin 补
|
|
|
480
697
|
|
|
481
698
|
it("install:canonical_origin 缺失(冒烟探针 URL 恒相对)→ 明确报错指向 vars.canonical_origin", async () => {
|
|
482
699
|
// 绝对基址(外部 CDN)也要 canonical origin——冒烟探针 URL 恒为站点相对路径
|
|
700
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
483
701
|
const api = createMarketApi(makeEnv({ canonicalOrigin: undefined }));
|
|
484
702
|
fetchCalls.length = 0;
|
|
485
703
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" }, headers: { host: FORGED } };
|
|
@@ -488,16 +706,6 @@ describe("相对基址(REG-CS6:/cdn 自供按配置的 canonical_origin 补
|
|
|
488
706
|
expect(last?.msg).toContain("vars.canonical_origin");
|
|
489
707
|
expect(fetchCalls.length).toBe(0);
|
|
490
708
|
});
|
|
491
|
-
|
|
492
|
-
it("绝对基址(外部 CDN)index + canonical_origin 缺失 → 无需 origin,索引照常", async () => {
|
|
493
|
-
const api = createMarketApi(makeEnv({ canonicalOrigin: undefined }));
|
|
494
|
-
fetchCalls.length = 0;
|
|
495
|
-
g.http = { user: { id: 1 }, headers: { host: FORGED } };
|
|
496
|
-
await api.index.get();
|
|
497
|
-
expect(last?.status).toBe(200);
|
|
498
|
-
expect(fetchCalls).toContain(`${CDN}catalog.yaml`);
|
|
499
|
-
expect(fetchCalls.some(u => String(u).includes(FORGED))).toBe(false);
|
|
500
|
-
});
|
|
501
709
|
});
|
|
502
710
|
|
|
503
711
|
describe("POST install/cancel(P3-12 端点接线,U5/P3-8 令牌的消费面)", () => {
|
|
@@ -523,6 +731,7 @@ describe("POST install/cancel(P3-12 端点接线,U5/P3-8 令牌的消费面
|
|
|
523
731
|
},
|
|
524
732
|
},
|
|
525
733
|
}));
|
|
734
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
526
735
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
527
736
|
const running = api.install.post(); // 不 await——在途安装
|
|
528
737
|
await vi.waitFor(async () => {
|
|
@@ -819,15 +1028,10 @@ describe("POST enable(生命周期开关:P1-13 瘦客户端触发面,四
|
|
|
819
1028
|
});
|
|
820
1029
|
});
|
|
821
1030
|
|
|
822
|
-
describe("GET index(
|
|
823
|
-
|
|
824
|
-
const api = createMarketApi(makeEnv({ cdnBase: async () => null }));
|
|
825
|
-
await api.index.get();
|
|
826
|
-
expect(last?.status).toBe(503);
|
|
827
|
-
expect(String(last?.msg)).toContain("未配置市场地址");
|
|
828
|
-
});
|
|
1031
|
+
describe("GET index(v4 §四:DB 目录分页 + 本地 state 对比;索引机制退役)", () => {
|
|
1032
|
+
const LATEST_DESCRIPTION = "演示方案:提供示例 CRUD 与菜单路由,覆盖安装、升级、回滚与历史留痕全链路。";
|
|
829
1033
|
|
|
830
|
-
it("聚合对比:
|
|
1034
|
+
it("聚合对比:DB 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/publisher_key/license/summary/description/trust_state/key_id/operator(P2-11);本地独有方案也列出;零索引出站", async () => {
|
|
831
1035
|
const env = makeEnv();
|
|
832
1036
|
const state = await createStateStore(env.statePath);
|
|
833
1037
|
await state.write({
|
|
@@ -836,45 +1040,83 @@ describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", ()
|
|
|
836
1040
|
"legacy": { version: "9.9.9", enabled: false, installedAt: 2 },
|
|
837
1041
|
},
|
|
838
1042
|
});
|
|
1043
|
+
seedRelease("demo-solution", "0.1.0", { published_at: 1000, manifest: manifestFixture("0.1.0") });
|
|
1044
|
+
seedRelease("demo-solution", "0.2.0", { published_at: 2000, manifest: { ...manifestFixture("0.2.0"), description: LATEST_DESCRIPTION } });
|
|
1045
|
+
seedRelease("wiki", "1.3.0", { published_at: 1500 });
|
|
839
1046
|
const api = createMarketApi(env);
|
|
840
1047
|
await api.index.get();
|
|
841
1048
|
expect(last?.status).toBe(200);
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
expect(
|
|
1049
|
+
// 直读 DB:零索引出站
|
|
1050
|
+
expect(fetchCalls.some(u => u.endsWith(".yaml") || u.endsWith(".yaml.sig"))).toBe(false);
|
|
1051
|
+
const d = last?.data as { list: unknown[], total: number, page: number, size: number };
|
|
1052
|
+
expect(d.total).toBe(3);
|
|
1053
|
+
expect(d.page).toBe(1);
|
|
1054
|
+
expect(d.size).toBe(20);
|
|
1055
|
+
expect(d.list).toEqual([
|
|
846
1056
|
{ 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", publisher_key: "test.publisher", license: "MIT", summary: "演示方案", description: LATEST_DESCRIPTION, trust_state: "trusted", key_id: "test.publisher", operator: null },
|
|
847
|
-
{ name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, publisher_key: null, license: null, summary: null, description: null, trust_state: null, key_id: null, operator: null },
|
|
848
1057
|
{ name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, publisher_key: null, license: null, summary: null, description: null, trust_state: null, key_id: null, operator: null },
|
|
1058
|
+
{ name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, publisher_key: "test.publisher", license: null, summary: null, description: null, trust_state: null, key_id: null, operator: null },
|
|
849
1059
|
]);
|
|
850
1060
|
});
|
|
851
1061
|
|
|
1062
|
+
it("翻页:?page=&size= 截窗口(产品名字典序);末页 truncated 语义由 total/size 承载", async () => {
|
|
1063
|
+
for (let i = 1; i <= 5; i++)
|
|
1064
|
+
seedRelease(`pkg-${String(i).padStart(2, "0")}`, "0.1.0");
|
|
1065
|
+
const api = createMarketApi(makeEnv());
|
|
1066
|
+
g.http = { user: { id: 1 }, query: { page: "2", size: "2" } };
|
|
1067
|
+
await api.index.get();
|
|
1068
|
+
expect(last?.status).toBe(200);
|
|
1069
|
+
const d = last?.data as { list: Array<{ name: string }>, total: number, page: number, size: number };
|
|
1070
|
+
expect(d.total).toBe(5);
|
|
1071
|
+
expect(d.page).toBe(2);
|
|
1072
|
+
expect(d.size).toBe(2);
|
|
1073
|
+
expect(d.list.map(r => r.name)).toEqual(["pkg-03", "pkg-04"]);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it("yanked 版本不计入 latest/available(卡片只见在架版本),全 yanked 产品 latest=null", async () => {
|
|
1077
|
+
seedRelease("demo-solution", "0.2.0", { yanked: 1, published_at: 2000, manifest: manifestFixture("0.2.0") });
|
|
1078
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
1079
|
+
seedRelease("gone", "1.0.0", { yanked: 1, manifest: { name: "gone", version: "1.0.0" } });
|
|
1080
|
+
const api = createMarketApi(makeEnv());
|
|
1081
|
+
g.http = { user: { id: 1 } };
|
|
1082
|
+
await api.index.get();
|
|
1083
|
+
const d = last?.data as { list: Array<{ name: string, latest: string | null, available: string[] }> };
|
|
1084
|
+
const demo = d.list.find(r => r.name === "demo-solution")!;
|
|
1085
|
+
expect(demo.latest).toBe("0.1.0");
|
|
1086
|
+
expect(demo.available).toEqual(["0.1.0"]);
|
|
1087
|
+
expect(d.list.find(r => r.name === "gone")?.latest).toBeNull();
|
|
1088
|
+
});
|
|
1089
|
+
|
|
852
1090
|
it("operator 回落 history 行(P2-11:最近一次 install 动作的 operator;表缺失/无行 → null 不炸)", async () => {
|
|
853
1091
|
dbo.prepare("insert into market_history (solution, version, action, status, operator, message, created_at) values (?, ?, ?, ?, ?, ?, ?)")
|
|
854
1092
|
.run("demo-solution", "0.1.0", "install", "done", "alice", "", 1700000000);
|
|
1093
|
+
seedRelease("demo-solution", "0.1.0");
|
|
855
1094
|
const api = createMarketApi(makeEnv());
|
|
856
1095
|
g.http = { user: { id: 1 } };
|
|
857
1096
|
await api.index.get();
|
|
858
1097
|
expect(last?.status).toBe(200);
|
|
859
|
-
const
|
|
860
|
-
expect(
|
|
861
|
-
expect(rows.find(r => r.name === "wiki")?.operator).toBeNull();
|
|
1098
|
+
const d = last?.data as { list: Array<{ name: string, operator: string | null }> };
|
|
1099
|
+
expect(d.list.find(r => r.name === "demo-solution")?.operator).toBe("alice");
|
|
862
1100
|
});
|
|
863
1101
|
|
|
864
|
-
it("
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1102
|
+
it("operator 批量口径一致(minor M6:一页多产品,每产品取各自最近一次 install operator,互不穿串)", async () => {
|
|
1103
|
+
const ins = dbo.prepare("insert into market_history (solution, version, action, status, operator, message, created_at) values (?, ?, ?, ?, ?, ?, ?)");
|
|
1104
|
+
ins.run("demo-solution", "0.1.0", "install", "done", "alice", "", 1700000000);
|
|
1105
|
+
ins.run("demo-solution", "0.1.1", "install", "done", "bob", "", 1700000001); // 更近一次 → demo 应取 bob
|
|
1106
|
+
ins.run("wiki", "1.0.0", "install", "done", "carol", "", 1700000002);
|
|
1107
|
+
seedRelease("demo-solution", "0.1.1");
|
|
1108
|
+
seedRelease("wiki", "1.0.0");
|
|
1109
|
+
const api = createMarketApi(makeEnv());
|
|
871
1110
|
g.http = { user: { id: 1 } };
|
|
872
1111
|
await api.index.get();
|
|
873
|
-
expect(last?.status).toBe(
|
|
874
|
-
|
|
1112
|
+
expect(last?.status).toBe(200);
|
|
1113
|
+
const d = last?.data as { list: Array<{ name: string, operator: string | null }> };
|
|
1114
|
+
expect(d.list.find(r => r.name === "demo-solution")?.operator).toBe("bob");
|
|
1115
|
+
expect(d.list.find(r => r.name === "wiki")?.operator).toBe("carol");
|
|
875
1116
|
});
|
|
876
1117
|
|
|
877
1118
|
it("P2-12 bootstrap key(G3 双轨第一轨):信任库为空 + bootstrapKey 注入 → cdn 首装直达 done 不落 needs_trust", async () => {
|
|
1119
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
878
1120
|
await fsp.rm(join(sharedRoot, ".solutions", "trusted-keys.yaml"), { force: true }); // beforeEach 已播种,删库文件模拟全新站点
|
|
879
1121
|
const env = makeEnv({ bootstrapKey: { key_id: "test.publisher", public_key: TEST_KEY.publicKeyHex } });
|
|
880
1122
|
const api = createMarketApi(env);
|
|
@@ -889,6 +1131,7 @@ describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", ()
|
|
|
889
1131
|
});
|
|
890
1132
|
|
|
891
1133
|
it("P2-12:无 bootstrapKey(内置未回填口径)+ 信任库为空 → needs_trust(双轨第二轨 TOFU 不失守)", async () => {
|
|
1134
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
892
1135
|
await fsp.rm(join(sharedRoot, ".solutions", "trusted-keys.yaml"), { force: true });
|
|
893
1136
|
const api = createMarketApi(makeEnv());
|
|
894
1137
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
@@ -896,513 +1139,6 @@ describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", ()
|
|
|
896
1139
|
const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
|
|
897
1140
|
expect((await getTask(taskDir, taskId))?.status).toBe("needs_trust");
|
|
898
1141
|
});
|
|
899
|
-
|
|
900
|
-
it("archive.file 邪例表(P2-11 索引投毒加固:file 逐字 <name>-<version>.tgz)→ 502 拒", async () => {
|
|
901
|
-
for (const file of ["../evil.tgz", "/etc/hosts.tgz", "other-0.1.0.tgz", "demo-solution-9.9.9.tgz"]) {
|
|
902
|
-
const catalog = JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 1 }, solutions: [{ name: "demo-solution", shards: [1] }] });
|
|
903
|
-
const base = manifestFixture("0.1.0");
|
|
904
|
-
const shard = JSON.stringify({ solutions: [{ name: "demo-solution", latest: "0.1.0", versions: [{ ...base, archive: { ...base.archive, file } }] }] });
|
|
905
|
-
// 本用例聚焦 file 逐字校验:索引照常签名,否则会先被「缺签名」挡下而测不到 file 分支
|
|
906
|
-
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
907
|
-
const shardSig = JSON.stringify(signIndex("shard", 1, shard, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
908
|
-
const fetchFn = (async (url: string) => {
|
|
909
|
-
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? shard : null;
|
|
910
|
-
if (text !== null)
|
|
911
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
912
|
-
if (String(url).endsWith(".yaml.sig")) {
|
|
913
|
-
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
914
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
915
|
-
}
|
|
916
|
-
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer }; // .tgz 下载字节
|
|
917
|
-
}) as FetchFace;
|
|
918
|
-
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
919
|
-
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
920
|
-
await api.install.post();
|
|
921
|
-
expect(last?.status, `file=${file}`).toBe(502);
|
|
922
|
-
expect(String(last?.msg), `file=${file}`).toContain("逐字");
|
|
923
|
-
}
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
it("索引不可达 → 502 可读错误不崩", async () => {
|
|
927
|
-
const api = createMarketApi(makeEnv({
|
|
928
|
-
fetch: (async () => ({ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) })) as FetchFace,
|
|
929
|
-
}));
|
|
930
|
-
await api.index.get();
|
|
931
|
-
expect(last?.status).toBe(502);
|
|
932
|
-
});
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
describe("REG-CS1 索引签名强制(G4 fail-closed:署名相符采用 / 篡改拒 / 缺失即拒 / 不同代即拒)", () => {
|
|
936
|
-
/** 注入 .sig 的三级 CDN:非空 .sig 进验签分支;缺/空 .sig → 404(REG-CS1 起即拒,不再容忍) */
|
|
937
|
-
function signingCdnFetch(sigs: { catalog?: string, shard?: string }): FetchFace {
|
|
938
|
-
return ((url: string, init?: unknown) => {
|
|
939
|
-
fetchCalls.push(url);
|
|
940
|
-
fetchInits.push(init);
|
|
941
|
-
if (String(url).endsWith(".yaml.sig")) {
|
|
942
|
-
const kind = String(url).includes("shards") ? "shard" : "catalog";
|
|
943
|
-
const body = sigs[kind];
|
|
944
|
-
if (body !== undefined && body.trim() !== "")
|
|
945
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(body).buffer as ArrayBuffer };
|
|
946
|
-
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; // 缺失/空 .sig
|
|
947
|
-
}
|
|
948
|
-
const text = String(url).endsWith("catalog.yaml")
|
|
949
|
-
? CATALOG
|
|
950
|
-
: String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
951
|
-
if (text !== null)
|
|
952
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
953
|
-
if (String(url).endsWith(".tgz"))
|
|
954
|
-
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
955
|
-
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
956
|
-
}) as FetchFace;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
/**
|
|
960
|
-
* 代次可配的三级 CDN:catalog 正文 seq、catalog 签名 seq、catalog 对 shard 1 的钉值、
|
|
961
|
-
* shard 1 签名 seq 分别可设——用来造「新总目录 + 旧分片」「改了正文没换签名」等中间态。
|
|
962
|
-
* 分片正文恒为 SHARD_1(纯内容无 seq:代次只由签名与 catalog 钉值携带)。
|
|
963
|
-
*/
|
|
964
|
-
function generationCdnFetch(args: { catalogSeq: number, shardPin: number, shardSigSeq: number }): FetchFace {
|
|
965
|
-
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] }] });
|
|
966
|
-
const catalogSig = JSON.stringify(signIndex("catalog", args.catalogSeq, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
967
|
-
const shardSig = JSON.stringify(signIndex("shard", args.shardSigSeq, SHARD_1, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
968
|
-
return ((url: string) => {
|
|
969
|
-
fetchCalls.push(url);
|
|
970
|
-
if (String(url).endsWith(".yaml.sig")) {
|
|
971
|
-
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
972
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
973
|
-
}
|
|
974
|
-
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
975
|
-
if (text !== null)
|
|
976
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
977
|
-
if (String(url).endsWith(".tgz"))
|
|
978
|
-
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
979
|
-
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
980
|
-
}) as FetchFace;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
it("署名相符:catalog/shard 均带合法 .sig → index 照常 200(G4 采用,验签分支确已触发)", async () => {
|
|
984
|
-
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ catalog: CATALOG_SIG, shard: SHARD_1_SIG }) }));
|
|
985
|
-
g.http = { user: { id: 1 } };
|
|
986
|
-
await api.index.get();
|
|
987
|
-
expect(last?.status).toBe(200);
|
|
988
|
-
expect((last?.data as unknown[]).length).toBeGreaterThan(0);
|
|
989
|
-
expect(fetchCalls.some(u => u.endsWith("catalog.yaml.sig"))).toBe(true);
|
|
990
|
-
expect(fetchCalls.some(u => u.endsWith("shards/1.yaml.sig"))).toBe(true);
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
it("篡改拒:catalog .sig 对别的字节签名(非索引真值)→ verifyIndexSig 不符 → 502 fail-closed(G4 不吞)", async () => {
|
|
994
|
-
const catalogSig = JSON.stringify(signIndex("catalog", SEQ, `${CATALOG}\n# tampered`, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
995
|
-
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ catalog: catalogSig, shard: SHARD_1_SIG }) }));
|
|
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_mismatch");
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
it("缺失即拒:CDN 不回 .sig → 502 index_sig_missing(不再「滚动发布期容忍」)+两步补签提示", async () => {
|
|
1003
|
-
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({}) }));
|
|
1004
|
-
g.http = { user: { id: 1 } };
|
|
1005
|
-
await api.index.get();
|
|
1006
|
-
expect(last?.status).toBe(502);
|
|
1007
|
-
expect(String(last?.msg)).toContain("index_sig_missing");
|
|
1008
|
-
expect(String(last?.msg)).toContain("ojm market build-index");
|
|
1009
|
-
expect(String(last?.msg)).toContain("ojm publish");
|
|
1010
|
-
});
|
|
1011
|
-
|
|
1012
|
-
it("空 .sig 等同缺失 → 502 index_sig_missing(不能靠空体绕过)", async () => {
|
|
1013
|
-
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ catalog: " ", shard: " " }) }));
|
|
1014
|
-
g.http = { user: { id: 1 } };
|
|
1015
|
-
await api.index.get();
|
|
1016
|
-
expect(last?.status).toBe(502);
|
|
1017
|
-
expect(String(last?.msg)).toContain("index_sig_missing");
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
it("签名未覆盖代次(旧格式无 seq)→ 502 index_sig_mismatch(不默认通过)", async () => {
|
|
1021
|
-
const legacy = JSON.stringify({ alg: "Ed25519", key_id: "test.publisher", public_key: TEST_KEY.publicKeyHex, value: "AAAA", canon: "v1" });
|
|
1022
|
-
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({ catalog: legacy, shard: legacy }) }));
|
|
1023
|
-
g.http = { user: { id: 1 } };
|
|
1024
|
-
await api.index.get();
|
|
1025
|
-
expect(last?.status).toBe(502);
|
|
1026
|
-
expect(String(last?.msg)).toContain("index_sig_mismatch");
|
|
1027
|
-
});
|
|
1028
|
-
|
|
1029
|
-
it("跨文件不同代(红队 F2):总目录第 7 代钉 shard 7、分片签名第 6 代 → 502 index_generation_mismatch(提示源在重建,不是「被篡改」)", async () => {
|
|
1030
|
-
const api = createMarketApi(makeEnv({ fetch: generationCdnFetch({ catalogSeq: 7, shardPin: 7, shardSigSeq: 6 }) }));
|
|
1031
|
-
g.http = { user: { id: 1 } };
|
|
1032
|
-
await api.index.get();
|
|
1033
|
-
expect(last?.status).toBe(502);
|
|
1034
|
-
expect(String(last?.msg)).toContain("index_generation_mismatch");
|
|
1035
|
-
expect(String(last?.msg)).toContain("源正在重建索引,请稍后重试");
|
|
1036
|
-
expect(String(last?.msg)).not.toContain("被篡改");
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
it("正文与签名不同代(改了正文没换签名)→ 502 index_generation_mismatch", async () => {
|
|
1040
|
-
// catalog 正文 seq=7 但签名 seq=6:body-seq 与 sig-seq 对不上(半个写入)
|
|
1041
|
-
const catalog = JSON.stringify({ seq: 7, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 6 }, solutions: [{ name: "demo-solution", shards: [1] }] });
|
|
1042
|
-
const catalogSig = JSON.stringify(signIndex("catalog", 6, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1043
|
-
const shardSig = JSON.stringify(signIndex("shard", 6, SHARD_1, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1044
|
-
const fetchFn = ((url: string) => {
|
|
1045
|
-
fetchCalls.push(url);
|
|
1046
|
-
if (String(url).endsWith(".yaml.sig")) {
|
|
1047
|
-
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
1048
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
1049
|
-
}
|
|
1050
|
-
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
1051
|
-
if (text !== null)
|
|
1052
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1053
|
-
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1054
|
-
}) as FetchFace;
|
|
1055
|
-
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
1056
|
-
g.http = { user: { id: 1 } };
|
|
1057
|
-
await api.index.get();
|
|
1058
|
-
expect(last?.status).toBe(502);
|
|
1059
|
-
expect(String(last?.msg)).toContain("index_generation_mismatch");
|
|
1060
|
-
});
|
|
1061
|
-
|
|
1062
|
-
it("同代放行:catalog 第 7 代、钉 shard 7、分片签名第 7 代 → 200(不误伤正常重建)", async () => {
|
|
1063
|
-
const api = createMarketApi(makeEnv({ fetch: generationCdnFetch({ catalogSeq: 7, shardPin: 7, shardSigSeq: 7 }) }));
|
|
1064
|
-
g.http = { user: { id: 1 } };
|
|
1065
|
-
await api.index.get();
|
|
1066
|
-
expect(last?.status).toBe(200);
|
|
1067
|
-
});
|
|
1068
|
-
|
|
1069
|
-
it("byte 相同分片跨代复用:catalog 第 7 代钉 shard 3、分片签名第 3 代 → 200(钉值口径,非恒等 catalog 代次)", async () => {
|
|
1070
|
-
const api = createMarketApi(makeEnv({ fetch: generationCdnFetch({ catalogSeq: 7, shardPin: 3, shardSigSeq: 3 }) }));
|
|
1071
|
-
g.http = { user: { id: 1 } };
|
|
1072
|
-
await api.index.get();
|
|
1073
|
-
expect(last?.status).toBe(200);
|
|
1074
|
-
});
|
|
1075
|
-
|
|
1076
|
-
it("安装侧同判定:缺签名 → install 502 index_sig_missing(离线 verify 与站点安装不能一个拒一个放)", async () => {
|
|
1077
|
-
const api = createMarketApi(makeEnv({ fetch: signingCdnFetch({}) }));
|
|
1078
|
-
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
1079
|
-
await api.install.post();
|
|
1080
|
-
expect(last?.status).toBe(502);
|
|
1081
|
-
expect(String(last?.msg)).toContain("index_sig_missing");
|
|
1082
|
-
});
|
|
1083
|
-
});
|
|
1084
|
-
|
|
1085
|
-
describe("REG-CS2 索引新鲜度(过期即拒 / 代次回退即拒 / 状态损坏 fail-closed)", () => {
|
|
1086
|
-
/** 代次与时效可配的三级 CDN:catalog 正文 seq 与时效封套可设;分片与 catalog 同代(钉值 = seq,签名 seq = 钉值) */
|
|
1087
|
-
function timedCdnFetch(args: { seq: number, shardPin?: number, expiresAt?: number | null }): FetchFace {
|
|
1088
|
-
const pin = args.shardPin ?? args.seq;
|
|
1089
|
-
const catalog = JSON.stringify({
|
|
1090
|
-
seq: args.seq,
|
|
1091
|
-
// expiresAt = null → 索引不带时效封套(旧 ojm 产物)
|
|
1092
|
-
...(args.expiresAt === null ? {} : { issued_at: ISSUED_AT, expires_at: args.expiresAt ?? EXPIRES_AT }),
|
|
1093
|
-
shards: { 1: pin },
|
|
1094
|
-
solutions: [{ name: "demo-solution", shards: [1] }],
|
|
1095
|
-
});
|
|
1096
|
-
const catalogSig = JSON.stringify(signIndex("catalog", args.seq, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1097
|
-
const shardSig = JSON.stringify(signIndex("shard", pin, SHARD_1, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1098
|
-
return ((url: string) => {
|
|
1099
|
-
fetchCalls.push(url);
|
|
1100
|
-
if (String(url).endsWith(".yaml.sig")) {
|
|
1101
|
-
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
1102
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
1103
|
-
}
|
|
1104
|
-
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
1105
|
-
if (text !== null)
|
|
1106
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1107
|
-
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
1108
|
-
}) as FetchFace;
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
it("索引已过期(expires_at 已过)→ 502 index_expired", async () => {
|
|
1112
|
-
const api = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 1, expiresAt: ISSUED_AT - 1 }) }));
|
|
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("索引不带时效(旧 ojm 产物)→ 502 index_expired(不可判定即按过期处理)", async () => {
|
|
1120
|
-
const api = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 1, expiresAt: null }) }));
|
|
1121
|
-
g.http = { user: { id: 1 } };
|
|
1122
|
-
await api.index.get();
|
|
1123
|
-
expect(last?.status).toBe(502);
|
|
1124
|
-
expect(String(last?.msg)).toContain("index_expired");
|
|
1125
|
-
});
|
|
1126
|
-
|
|
1127
|
-
it("源返回更旧的代次(镜像落后 / 旧快照回放)→ 502 index_stale,且水位不被拉低", async () => {
|
|
1128
|
-
// 先见第 7 代(水位落库),同源再返回第 3 代 → 回放被挡
|
|
1129
|
-
const seen7 = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 7 }) }));
|
|
1130
|
-
g.http = { user: { id: 1 } };
|
|
1131
|
-
await seen7.index.get();
|
|
1132
|
-
expect(last?.status).toBe(200);
|
|
1133
|
-
|
|
1134
|
-
const replayed = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 3 }) }));
|
|
1135
|
-
g.http = { user: { id: 1 } };
|
|
1136
|
-
await replayed.index.get();
|
|
1137
|
-
expect(last?.status).toBe(502);
|
|
1138
|
-
expect(String(last?.msg)).toContain("index_stale");
|
|
1139
|
-
const row = dbo.prepare("SELECT max_seq FROM reg_source_seq WHERE source_url = ?").get(CDN.replace(/\/$/, "")) as { max_seq: number } | undefined;
|
|
1140
|
-
expect(row?.max_seq).toBe(7);
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
it("同代重复拉取 → 放行(幂等,不误伤正常重复请求)", async () => {
|
|
1144
|
-
const fetchFn = timedCdnFetch({ seq: 4 });
|
|
1145
|
-
g.http = { user: { id: 1 } };
|
|
1146
|
-
await createMarketApi(makeEnv({ fetch: fetchFn })).index.get();
|
|
1147
|
-
expect(last?.status).toBe(200);
|
|
1148
|
-
await createMarketApi(makeEnv({ fetch: fetchFn })).index.get();
|
|
1149
|
-
expect(last?.status).toBe(200);
|
|
1150
|
-
});
|
|
1151
|
-
|
|
1152
|
-
it("代次水位表缺失(未 migrate)→ 502 index_state_corrupt(fail-closed,不静默当「第一次见」)", async () => {
|
|
1153
|
-
dbo.exec("DROP TABLE reg_source_seq");
|
|
1154
|
-
const api = createMarketApi(makeEnv({ fetch: timedCdnFetch({ seq: 1 }) }));
|
|
1155
|
-
g.http = { user: { id: 1 } };
|
|
1156
|
-
await api.index.get();
|
|
1157
|
-
expect(last?.status).toBe(502);
|
|
1158
|
-
expect(String(last?.msg)).toContain("index_state_corrupt");
|
|
1159
|
-
});
|
|
1160
|
-
});
|
|
1161
|
-
|
|
1162
|
-
describe("REG-CS5 三级索引(装箱分片 + 条件请求 + DIP 读取源 + 读写两侧半写防线)", () => {
|
|
1163
|
-
/**
|
|
1164
|
-
* 2500 版本跨 3 片的装箱索引(BDD①;装箱口径与写侧 sharding.ts 同参:单片 ≤1000 版本):
|
|
1165
|
-
* 1 号片持 1.0.0..1.0.999、2 号片持 1.0.1000..1.0.1999、3 号片持 1.0.2000..1.0.2499,
|
|
1166
|
-
* 最高片持最新版。两端界版本给完整 Form B manifest(安装链消费面),
|
|
1167
|
-
* 其余条目只要 name/version/archive 三要素(latest 单调与 available 消费)。
|
|
1168
|
-
*/
|
|
1169
|
-
function bigIndexFetch(): FetchFace {
|
|
1170
|
-
const cut = 1000;
|
|
1171
|
-
const entryOf = (v: string, full: boolean): Record<string, unknown> => full
|
|
1172
|
-
? manifestFixture(v)
|
|
1173
|
-
: { name: "demo-solution", version: v, archive: { file: `demo-solution-${v}.tgz`, sha256: SHA256, size: ARCHIVE.byteLength } };
|
|
1174
|
-
const range = (from: number, to: number, fullAt: number[]): Array<Record<string, unknown>> =>
|
|
1175
|
-
Array.from({ length: to - from + 1 }, (_, i) => entryOf(`1.0.${from + i}`, fullAt.includes(from + i)));
|
|
1176
|
-
const shard1 = JSON.stringify({ solutions: [{ name: "demo-solution", latest: `1.0.${cut - 1}`, versions: range(0, cut - 1, [0]) }] });
|
|
1177
|
-
const shard2 = JSON.stringify({ solutions: [{ name: "demo-solution", latest: `1.0.${cut * 2 - 1}`, versions: range(cut, cut * 2 - 1, []) }] });
|
|
1178
|
-
const shard3 = JSON.stringify({ solutions: [{ name: "demo-solution", latest: "1.0.2499", versions: range(cut * 2, 2499, [2499]) }] });
|
|
1179
|
-
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] }] });
|
|
1180
|
-
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1181
|
-
const shardSigs = [shard1, shard2, shard3].map(body => JSON.stringify(signIndex("shard", 1, body, TEST_KEY.privateKeyPem, "test.publisher")));
|
|
1182
|
-
return ((url: string) => {
|
|
1183
|
-
fetchCalls.push(url);
|
|
1184
|
-
if (String(url).endsWith(".yaml.sig")) {
|
|
1185
|
-
const hit = /shards\/(\d+)\.yaml\.sig$/.exec(String(url));
|
|
1186
|
-
const sig = hit === null ? catalogSig : shardSigs[Number(hit[1]) - 1]!;
|
|
1187
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
1188
|
-
}
|
|
1189
|
-
const text = String(url).endsWith("catalog.yaml")
|
|
1190
|
-
? catalog
|
|
1191
|
-
: String(url).endsWith("shards/1.yaml") ? shard1 : String(url).endsWith("shards/2.yaml") ? shard2 : String(url).endsWith("shards/3.yaml") ? shard3 : null;
|
|
1192
|
-
if (text !== null)
|
|
1193
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1194
|
-
if (String(url).endsWith(".tgz"))
|
|
1195
|
-
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer as ArrayBuffer };
|
|
1196
|
-
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1197
|
-
}) as FetchFace;
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
it("BDD① 装箱分片:2500 版本跨 3 片,latest 安装只拉最高片(≤ 1 catalog + 1 shard)→ done", async () => {
|
|
1201
|
-
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch(), reload: reloadStubFor("1.0.2499") }));
|
|
1202
|
-
fetchCalls.length = 0;
|
|
1203
|
-
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
|
|
1204
|
-
await api.install.post();
|
|
1205
|
-
expect(last?.status).toBe(200);
|
|
1206
|
-
expect(fetchCalls).toContain(`${CDN}catalog.yaml`);
|
|
1207
|
-
expect(fetchCalls).toContain(`${CDN}shards/3.yaml`);
|
|
1208
|
-
// 降序尝试:最高片即中,低片一次都不拉(索引请求 ≤ 1 catalog + 1 shard)
|
|
1209
|
-
expect(fetchCalls.some(u => u.endsWith("shards/1.yaml"))).toBe(false);
|
|
1210
|
-
expect(fetchCalls.some(u => u.endsWith("shards/2.yaml"))).toBe(false);
|
|
1211
|
-
expect(fetchCalls).toContain(`${CDN}demo-solution-1.0.2499.tgz`);
|
|
1212
|
-
const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
|
|
1213
|
-
const rec = await getTask(taskDir, taskId);
|
|
1214
|
-
expect(rec?.version).toBe("1.0.2499");
|
|
1215
|
-
expect(rec?.status).toBe("done");
|
|
1216
|
-
});
|
|
1217
|
-
|
|
1218
|
-
it("BDD① 回落低片:显式指定最低版本 → 先试最高片未中,逐片回落到 1 号片命中 → done", async () => {
|
|
1219
|
-
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch(), reload: reloadStubFor("1.0.0") }));
|
|
1220
|
-
fetchCalls.length = 0;
|
|
1221
|
-
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "1.0.0", source: "cdn" } };
|
|
1222
|
-
await api.install.post();
|
|
1223
|
-
expect(last?.status).toBe(200);
|
|
1224
|
-
expect(fetchCalls).toContain(`${CDN}shards/3.yaml`); // 仍先试最高片
|
|
1225
|
-
expect(fetchCalls).toContain(`${CDN}shards/2.yaml`); // 未中 → 回落中片
|
|
1226
|
-
expect(fetchCalls).toContain(`${CDN}shards/1.yaml`); // 仍未中 → 回落低片命中
|
|
1227
|
-
expect(fetchCalls).toContain(`${CDN}demo-solution-1.0.0.tgz`);
|
|
1228
|
-
const taskId = (last?.data as { task_id?: unknown })?.task_id as string;
|
|
1229
|
-
const rec = await getTask(taskDir, taskId);
|
|
1230
|
-
expect(rec?.version).toBe("1.0.0");
|
|
1231
|
-
expect(rec?.status).toBe("done");
|
|
1232
|
-
});
|
|
1233
|
-
|
|
1234
|
-
it("页面 available 全集(回滚候选要版本全集):3 片 2500 版跨片全局升序,latest 取最高片", async () => {
|
|
1235
|
-
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
|
|
1236
|
-
g.http = { user: { id: 1 } };
|
|
1237
|
-
await api.index.get();
|
|
1238
|
-
expect(last?.status).toBe(200);
|
|
1239
|
-
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");
|
|
1240
|
-
expect(row?.latest).toBe("1.0.2499");
|
|
1241
|
-
expect(row?.available).toHaveLength(2500);
|
|
1242
|
-
expect(row?.available[0]).toBe("1.0.0");
|
|
1243
|
-
expect(row?.available[999]).toBe("1.0.999"); // 1 号片末条
|
|
1244
|
-
expect(row?.available[1000]).toBe("1.0.1000"); // 2 号片首条——跨片拼接处仍升序
|
|
1245
|
-
expect(row?.available[1999]).toBe("1.0.1999"); // 2 号片末条
|
|
1246
|
-
expect(row?.available[2000]).toBe("1.0.2000"); // 3 号片首条
|
|
1247
|
-
expect(row?.available[2499]).toBe("1.0.2499");
|
|
1248
|
-
expect([...row!.available].sort(compareVersions)).toEqual(row!.available);
|
|
1249
|
-
// latest 的 changelog/publisher 直接取分片条目(无独立聚合清单)
|
|
1250
|
-
expect(row?.changelog).toEqual(["1.0.2499 变更条目"]);
|
|
1251
|
-
expect(row?.publisher).toBe("oj-module");
|
|
1252
|
-
});
|
|
1253
|
-
|
|
1254
|
-
it("条件请求缓存(Q8 可用性通道):第二轮索引拉取带 If-None-Match → 304 不重传正文,缓存副本照常出数", async () => {
|
|
1255
|
-
// 独立基址:etagCache 是模块级(url → {etag, body}),与其余用例的 CDN 基址隔离
|
|
1256
|
-
const ETAG_BASE = "https://etag.example.com/cdn/";
|
|
1257
|
-
let notModified = 0;
|
|
1258
|
-
const fetchFn = ((url: string, init?: unknown) => {
|
|
1259
|
-
fetchCalls.push(url);
|
|
1260
|
-
fetchInits.push(init);
|
|
1261
|
-
const conditional = (init as { headers?: Record<string, string> } | undefined)?.headers?.["if-none-match"];
|
|
1262
|
-
const text = String(url).endsWith("catalog.yaml")
|
|
1263
|
-
? CATALOG
|
|
1264
|
-
: String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
1265
|
-
const sigText = String(url).endsWith("catalog.yaml.sig")
|
|
1266
|
-
? CATALOG_SIG
|
|
1267
|
-
: String(url).endsWith("shards/1.yaml.sig") ? SHARD_1_SIG : null;
|
|
1268
|
-
// 带条件头的请求:服务端认 ETag → 304 空正文(ok=false 但语义是「未变」)
|
|
1269
|
-
if (conditional !== undefined) {
|
|
1270
|
-
notModified++;
|
|
1271
|
-
return { ok: false, status: 304, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1272
|
-
}
|
|
1273
|
-
const body = text ?? sigText;
|
|
1274
|
-
if (body !== null) {
|
|
1275
|
-
return {
|
|
1276
|
-
ok: true,
|
|
1277
|
-
status: 200,
|
|
1278
|
-
arrayBuffer: async () => new TextEncoder().encode(body).buffer as ArrayBuffer,
|
|
1279
|
-
// 响应头读取面(site-facade 扩展):读 ETag 供下一轮 If-None-Match
|
|
1280
|
-
headers: { get: (name: string) => (name.toLowerCase() === "etag" ? `"idx-${body.length}"` : null) },
|
|
1281
|
-
};
|
|
1282
|
-
}
|
|
1283
|
-
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1284
|
-
}) as FetchFace;
|
|
1285
|
-
const api = createMarketApi(makeEnv({ cdnBase: async () => ETAG_BASE, fetch: fetchFn }));
|
|
1286
|
-
g.http = { user: { id: 1 } };
|
|
1287
|
-
// 第一轮:无缓存 → 四项索引请求(catalog 正文+签名、shard 正文+签名)全量 200 + ETag
|
|
1288
|
-
await api.index.get();
|
|
1289
|
-
expect(last?.status).toBe(200);
|
|
1290
|
-
const first = last?.data;
|
|
1291
|
-
expect(notModified).toBe(0);
|
|
1292
|
-
expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["if-none-match"] === undefined)).toBe(true);
|
|
1293
|
-
// 读面不发 no-cache:新鲜度由服务端缓存头 + 条件请求承载
|
|
1294
|
-
expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["cache-control"] === undefined)).toBe(true);
|
|
1295
|
-
// 第二轮:四项全部带 If-None-Match → 全部 304 不重传,缓存副本出数与首轮逐字一致
|
|
1296
|
-
await api.index.get();
|
|
1297
|
-
expect(last?.status).toBe(200);
|
|
1298
|
-
expect(last?.data).toEqual(first);
|
|
1299
|
-
const second = fetchInits.slice(4);
|
|
1300
|
-
expect(second).toHaveLength(4);
|
|
1301
|
-
expect(second.every(i => (i as { headers?: Record<string, string> }).headers?.["if-none-match"] !== undefined)).toBe(true);
|
|
1302
|
-
expect(notModified).toBe(4);
|
|
1303
|
-
});
|
|
1304
|
-
|
|
1305
|
-
it("DIP 本地目录索引源:indexSource 注入 → 零 HTTP 直读 catalog+shards,校验语义与 HTTP 实现同构", async () => {
|
|
1306
|
-
const dir = join(root, "cdn-mirror");
|
|
1307
|
-
await fsp.mkdir(join(dir, "shards"), { recursive: true });
|
|
1308
|
-
await fsp.writeFile(join(dir, "catalog.yaml"), CATALOG);
|
|
1309
|
-
await fsp.writeFile(join(dir, "catalog.yaml.sig"), CATALOG_SIG);
|
|
1310
|
-
await fsp.writeFile(join(dir, "shards", "1.yaml"), SHARD_1);
|
|
1311
|
-
await fsp.writeFile(join(dir, "shards", "1.yaml.sig"), SHARD_1_SIG);
|
|
1312
|
-
const api = createMarketApi(makeEnv({ indexSource: localIndexSource(dir) }));
|
|
1313
|
-
fetchCalls.length = 0;
|
|
1314
|
-
g.http = { user: { id: 1 } };
|
|
1315
|
-
await api.index.get();
|
|
1316
|
-
expect(last?.status).toBe(200);
|
|
1317
|
-
expect(fetchCalls.length).toBe(0); // 本地直读,零出站
|
|
1318
|
-
const row = (last?.data as Array<{ name: string, latest: string | null }>).find(r => r.name === "demo-solution");
|
|
1319
|
-
expect(row?.latest).toBe("0.2.0");
|
|
1320
|
-
});
|
|
1321
|
-
|
|
1322
|
-
it("本地目录索引源同构拒载:缺 .sig → 502 index_sig_missing(与 HTTP 实现一个判定)", async () => {
|
|
1323
|
-
const dir = join(root, "cdn-mirror-nosig");
|
|
1324
|
-
await fsp.mkdir(join(dir, "shards"), { recursive: true });
|
|
1325
|
-
await fsp.writeFile(join(dir, "catalog.yaml"), CATALOG);
|
|
1326
|
-
await fsp.writeFile(join(dir, "shards", "1.yaml"), SHARD_1);
|
|
1327
|
-
await fsp.writeFile(join(dir, "shards", "1.yaml.sig"), SHARD_1_SIG); // 只缺 catalog 的签名
|
|
1328
|
-
const api = createMarketApi(makeEnv({ indexSource: localIndexSource(dir) }));
|
|
1329
|
-
g.http = { user: { id: 1 } };
|
|
1330
|
-
await api.index.get();
|
|
1331
|
-
expect(last?.status).toBe(502);
|
|
1332
|
-
expect(String(last?.msg)).toContain("index_sig_missing");
|
|
1333
|
-
});
|
|
1334
|
-
|
|
1335
|
-
it("读侧半写防线:catalog 钉了 shards/2.yaml 但 CDN 缺该文件 → 502 fail-closed(不是「没有该方案」)", async () => {
|
|
1336
|
-
// 写侧原子落地序:分片正文 → 分片签名 → catalog 正文 → catalog 签名。
|
|
1337
|
-
// 此夹具 = catalog 已提交、shard 2 尚未落盘的中间态(读侧视角的半写)
|
|
1338
|
-
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] }] });
|
|
1339
|
-
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1340
|
-
const fetchFn = ((url: string) => {
|
|
1341
|
-
fetchCalls.push(url);
|
|
1342
|
-
if (String(url).includes("shards/2"))
|
|
1343
|
-
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; // 分片还没落盘
|
|
1344
|
-
if (String(url).endsWith("catalog.yaml.sig"))
|
|
1345
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(catalogSig).buffer as ArrayBuffer };
|
|
1346
|
-
if (String(url).endsWith("shards/1.yaml.sig"))
|
|
1347
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(SHARD_1_SIG).buffer as ArrayBuffer };
|
|
1348
|
-
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? SHARD_1 : null;
|
|
1349
|
-
if (text !== null)
|
|
1350
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1351
|
-
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1352
|
-
}) as FetchFace;
|
|
1353
|
-
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
1354
|
-
g.http = { user: { id: 1 } };
|
|
1355
|
-
await api.index.get();
|
|
1356
|
-
expect(last?.status).toBe(502);
|
|
1357
|
-
expect(String(last?.msg)).toContain("shards/2.yaml");
|
|
1358
|
-
});
|
|
1359
|
-
|
|
1360
|
-
it("索引结构破损邪例表(签名过的不可信输入,G4:结构破损与字节被改同类)→ 502 index_generation_mismatch", async () => {
|
|
1361
|
-
const brokenCatalogs = [
|
|
1362
|
-
// 分片钉值为空
|
|
1363
|
-
JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: {}, solutions: [{ name: "demo-solution", shards: [1] }] }),
|
|
1364
|
-
// 方案指向未钉分片(不静默跳过——那会伪装成「没有该方案」)
|
|
1365
|
-
JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 1 }, solutions: [{ name: "demo-solution", shards: [2] }] }),
|
|
1366
|
-
];
|
|
1367
|
-
for (const catalog of brokenCatalogs) {
|
|
1368
|
-
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1369
|
-
const fetchFn = ((url: string) => {
|
|
1370
|
-
if (String(url).endsWith("catalog.yaml.sig"))
|
|
1371
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(catalogSig).buffer as ArrayBuffer };
|
|
1372
|
-
if (String(url).endsWith("catalog.yaml"))
|
|
1373
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(catalog).buffer as ArrayBuffer };
|
|
1374
|
-
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1375
|
-
}) as FetchFace;
|
|
1376
|
-
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
1377
|
-
g.http = { user: { id: 1 } };
|
|
1378
|
-
await api.index.get();
|
|
1379
|
-
expect(last?.status).toBe(502);
|
|
1380
|
-
expect(String(last?.msg)).toContain("index_generation_mismatch");
|
|
1381
|
-
}
|
|
1382
|
-
});
|
|
1383
|
-
|
|
1384
|
-
it("分片结构破损(签名过但缺 solutions 数组)→ 502 fail-closed(不猜、不降级)", async () => {
|
|
1385
|
-
const shard = JSON.stringify({ packages: [] }); // 旧键名/畸形:分片解析面拒
|
|
1386
|
-
const catalog = JSON.stringify({ seq: 1, issued_at: ISSUED_AT, expires_at: EXPIRES_AT, shards: { 1: 1 }, solutions: [{ name: "demo-solution", shards: [1] }] });
|
|
1387
|
-
const catalogSig = JSON.stringify(signIndex("catalog", 1, catalog, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1388
|
-
const shardSig = JSON.stringify(signIndex("shard", 1, shard, TEST_KEY.privateKeyPem, "test.publisher"));
|
|
1389
|
-
const fetchFn = ((url: string) => {
|
|
1390
|
-
if (String(url).endsWith(".yaml.sig")) {
|
|
1391
|
-
const sig = String(url).includes("shards") ? shardSig : catalogSig;
|
|
1392
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(sig).buffer as ArrayBuffer };
|
|
1393
|
-
}
|
|
1394
|
-
const text = String(url).endsWith("catalog.yaml") ? catalog : String(url).endsWith("shards/1.yaml") ? shard : null;
|
|
1395
|
-
if (text !== null)
|
|
1396
|
-
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(text).buffer as ArrayBuffer };
|
|
1397
|
-
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1398
|
-
}) as FetchFace;
|
|
1399
|
-
const api = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
1400
|
-
g.http = { user: { id: 1 } };
|
|
1401
|
-
await api.index.get();
|
|
1402
|
-
expect(last?.status).toBe(502);
|
|
1403
|
-
expect(String(last?.msg)).toContain("shards/1.yaml");
|
|
1404
|
-
expect(String(last?.msg)).toContain("solutions");
|
|
1405
|
-
});
|
|
1406
1142
|
});
|
|
1407
1143
|
|
|
1408
1144
|
describe("hostVersions 缺省接线(B-11:C2-09 比对真源 = <dist>/versions.json,ojm 宿主构建产物)", () => {
|
|
@@ -1424,6 +1160,7 @@ describe("hostVersions 缺省接线(B-11:C2-09 比对真源 = <dist>/version
|
|
|
1424
1160
|
it("集成:env 未注入 hostVersions + <release 根>/versions.json 在盘 → install 全链 done(C2-09 比对走缺省真源)", async () => {
|
|
1425
1161
|
await fsp.mkdir(sharedRoot, { recursive: true });
|
|
1426
1162
|
await fsp.writeFile(join(sharedRoot, "versions.json"), JSON.stringify({ "@oj-module/runtime": "0.1.77" }));
|
|
1163
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
1427
1164
|
const env = makeEnv();
|
|
1428
1165
|
delete (env as Partial<MarketEnv>).hostVersions;
|
|
1429
1166
|
const api = createMarketApi(env);
|
|
@@ -1435,6 +1172,7 @@ describe("hostVersions 缺省接线(B-11:C2-09 比对真源 = <dist>/version
|
|
|
1435
1172
|
});
|
|
1436
1173
|
|
|
1437
1174
|
it("集成:versions.json 缺失且未注入 → C2-09 fail-closed,install 终态 failed@preflight", async () => {
|
|
1175
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
1438
1176
|
const env = makeEnv();
|
|
1439
1177
|
delete (env as Partial<MarketEnv>).hostVersions;
|
|
1440
1178
|
const api = createMarketApi(env);
|
|
@@ -1471,6 +1209,7 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
|
|
|
1471
1209
|
return base.run(argv, opts);
|
|
1472
1210
|
},
|
|
1473
1211
|
};
|
|
1212
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
1474
1213
|
const api = createMarketApi(makeEnv({ exec }));
|
|
1475
1214
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
1476
1215
|
await api.install.post();
|
|
@@ -1484,6 +1223,7 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
|
|
|
1484
1223
|
|
|
1485
1224
|
it("smoke 探针补全(REG-CS6):按配置的 canonical_origin 补全相对探针,绝不读请求 Host 头", async () => {
|
|
1486
1225
|
// 配置 canonical origin;伪造 Host 头(云元数据 IP)在场,验证出站不被其牵引
|
|
1226
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
1487
1227
|
const api = createMarketApi(makeEnv({ canonicalOrigin: "http://site.example:9781" }));
|
|
1488
1228
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" }, headers: { host: "169.254.169.254" } };
|
|
1489
1229
|
await api.install.post();
|
|
@@ -1500,6 +1240,7 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
|
|
|
1500
1240
|
});
|
|
1501
1241
|
|
|
1502
1242
|
it("ledgerHead 接真源:env.ledgerHeadOf 注入时 migrate 段按其值判禁降版(head 高于目标 seq → 拒切 failed@migrating)", async () => {
|
|
1243
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
1503
1244
|
const api = createMarketApi(makeEnv({ ledgerHeadOf: async () => 99 }));
|
|
1504
1245
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
1505
1246
|
await api.install.post();
|
|
@@ -1515,6 +1256,7 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
|
|
|
1515
1256
|
await writeLock(join(sharedRoot, "manifests.yaml"), { "demo-solution": "0.1.0", "other": "1.0.0" });
|
|
1516
1257
|
const state = await createStateStore(join(sharedRoot, ".solutions", "state.yaml"));
|
|
1517
1258
|
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1 } } });
|
|
1259
|
+
seedRelease("demo-solution", "0.1.0", { manifest: manifestFixture("0.1.0") });
|
|
1518
1260
|
const api = createMarketApi(makeEnv());
|
|
1519
1261
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "0.1.0", source: "cdn" } };
|
|
1520
1262
|
await api.install.post();
|
|
@@ -1563,146 +1305,49 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
|
|
|
1563
1305
|
});
|
|
1564
1306
|
});
|
|
1565
1307
|
|
|
1566
|
-
// ── 发布历史(GET /market/releases
|
|
1567
|
-
// 数据源是签名索引本身:每次读先把索引里出现过的 product@version 幂等补进 market_release,
|
|
1568
|
-
// 再按发布时间倒序分页。索引读不到不阻断查询(返回已入库行 + syncError)。
|
|
1308
|
+
// ── 发布历史(GET /oj-market/releases;v4 §四:数据源 = market_release 上传写入行,索引 syncReleases 退役)──
|
|
1569
1309
|
|
|
1570
1310
|
interface ReleasePageData {
|
|
1571
|
-
list: Array<{ id: number, product: string, version: string, published_at: number, published_at_source: string, yanked: number }>
|
|
1311
|
+
list: Array<{ id: number, product: string, version: string, key_id: string | null, sha256: string | null, size: number | null, published_at: number, published_at_source: string, yanked: number }>
|
|
1572
1312
|
total: number
|
|
1573
1313
|
page: number
|
|
1574
1314
|
size: number
|
|
1575
|
-
syncError: string | null
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
/** HEAD 可读 Last-Modified 的 CDN 面(其余请求复用 cdnFetch 的完整夹具) */
|
|
1579
|
-
function headAwareFetch(lastModified: string): FetchFace {
|
|
1580
|
-
return (async (url: string, init?: { method?: string }) => {
|
|
1581
|
-
if (init?.method === "HEAD") {
|
|
1582
|
-
return {
|
|
1583
|
-
ok: true,
|
|
1584
|
-
status: 200,
|
|
1585
|
-
arrayBuffer: async () => new ArrayBuffer(0),
|
|
1586
|
-
headers: { get: (name: string) => name.toLowerCase() === "last-modified" ? lastModified : null },
|
|
1587
|
-
};
|
|
1588
|
-
}
|
|
1589
|
-
return cdnFetch(url);
|
|
1590
|
-
}) as FetchFace;
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
/** 换一版分片正文(并按其字节重签,seq 与 catalog 同代),用于构造「索引说已下线」) */
|
|
1594
|
-
function shardWithYanked(): { body: string, sig: string } {
|
|
1595
|
-
const body = JSON.stringify({
|
|
1596
|
-
solutions: [{
|
|
1597
|
-
name: "demo-solution",
|
|
1598
|
-
latest: "0.2.0",
|
|
1599
|
-
versions: [
|
|
1600
|
-
{ ...manifestFixture("0.2.0"), description: LATEST_DESCRIPTION },
|
|
1601
|
-
{ ...manifestFixture("0.1.0"), yanked: true },
|
|
1602
|
-
],
|
|
1603
|
-
}],
|
|
1604
|
-
});
|
|
1605
|
-
return { body, sig: JSON.stringify(signIndex("shard", SEQ, body, TEST_KEY.privateKeyPem, "test.publisher")) };
|
|
1606
1315
|
}
|
|
1607
1316
|
|
|
1608
|
-
describe("GET /market/releases
|
|
1609
|
-
it("
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
expect(last?.status).toBe(200);
|
|
1614
|
-
const page = last?.data as ReleasePageData;
|
|
1615
|
-
expect(page.syncError).toBeNull();
|
|
1616
|
-
expect(page.total).toBe(3);
|
|
1617
|
-
expect(page.list.map(r => `${r.product}@${r.version}`)).toEqual(["wiki@1.3.0", "demo-solution@0.1.0", "demo-solution@0.2.0"]);
|
|
1618
|
-
expect(page.list.every(r => r.published_at === ISSUED_AT && r.published_at_source === "index")).toBe(true);
|
|
1619
|
-
// 每行先试 HEAD(拿真正的落 CDN 时刻),失败才回落——不是完全不问源
|
|
1620
|
-
expect(fetchInits.some(i => (i as { method?: string } | undefined)?.method === "HEAD")).toBe(true);
|
|
1621
|
-
});
|
|
1622
|
-
|
|
1623
|
-
it("HEAD 拿得到 Last-Modified → 用文件时间并标 source=file(索引时间只作兜底,不冒充精确)", async () => {
|
|
1624
|
-
const api = createMarketApi(makeEnv({ fetch: headAwareFetch("Wed, 01 Oct 2025 00:00:00 GMT") }));
|
|
1625
|
-
g.http = { user: { id: 1 } };
|
|
1626
|
-
await api.releases.get();
|
|
1627
|
-
expect(last?.status).toBe(200);
|
|
1628
|
-
const page = last?.data as ReleasePageData;
|
|
1629
|
-
const expected = Math.floor(Date.parse("Wed, 01 Oct 2025 00:00:00 GMT") / 1000);
|
|
1630
|
-
expect(page.list.every(r => r.published_at === expected && r.published_at_source === "file")).toBe(true);
|
|
1631
|
-
});
|
|
1632
|
-
|
|
1633
|
-
it("幂等 + append-only:二次读不重复插入,已入库的 published_at 不被索引时间改写", async () => {
|
|
1634
|
-
// 预置一行「上次记录的历史」:发布时间是事实,哪怕与本次索引的时间不同也不该被刷新
|
|
1635
|
-
dbo.prepare("INSERT INTO market_release (product, version, published_at, published_at_source, yanked, first_seen_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
1636
|
-
.run("demo-solution", "0.2.0", 1_600_000_000, "file", 0, 1_600_000_000);
|
|
1637
|
-
const api = createMarketApi(makeEnv());
|
|
1638
|
-
g.http = { user: { id: 1 } };
|
|
1639
|
-
await api.releases.get();
|
|
1640
|
-
await api.releases.get();
|
|
1641
|
-
expect(last?.status).toBe(200);
|
|
1642
|
-
const page = last?.data as ReleasePageData;
|
|
1643
|
-
expect(page.total).toBe(3);
|
|
1644
|
-
const rows = dbo.prepare("SELECT product, version, published_at FROM market_release ORDER BY product, version").all() as Array<{ product: string, version: string, published_at: number }>;
|
|
1645
|
-
expect(rows).toHaveLength(3);
|
|
1646
|
-
expect(rows.find(r => r.product === "demo-solution" && r.version === "0.2.0")?.published_at).toBe(1_600_000_000);
|
|
1647
|
-
});
|
|
1648
|
-
|
|
1649
|
-
it("yanked 跟随索引(0→1 与 1→0 双向):下线是当前状态,不是历史事实的改写", async () => {
|
|
1650
|
-
// 1→0:库里记着已下线,索引又回到在架
|
|
1651
|
-
dbo.prepare("INSERT INTO market_release (product, version, published_at, published_at_source, yanked, first_seen_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
1652
|
-
.run("demo-solution", "0.2.0", ISSUED_AT, "index", 1, ISSUED_AT);
|
|
1317
|
+
describe("GET /oj-market/releases(发布历史:DB 目录行直读 + 分页)", () => {
|
|
1318
|
+
it("直读上传写入行:published_at 倒序分页,字段齐全(含 yanked 当前态与 published_at_source)", async () => {
|
|
1319
|
+
seedRelease("demo-solution", "0.1.0", { published_at: 1000, manifest: manifestFixture("0.1.0") });
|
|
1320
|
+
seedRelease("demo-solution", "0.2.0", { published_at: 3000, yanked: 1, manifest: manifestFixture("0.2.0") });
|
|
1321
|
+
seedRelease("wiki", "1.0.0", { published_at: 2000, key_id: null });
|
|
1653
1322
|
const api = createMarketApi(makeEnv());
|
|
1654
1323
|
g.http = { user: { id: 1 } };
|
|
1655
1324
|
await api.releases.get();
|
|
1656
1325
|
expect(last?.status).toBe(200);
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
const api2 = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
1669
|
-
g.http = { user: { id: 1 } };
|
|
1670
|
-
await api2.releases.get();
|
|
1671
|
-
expect(last?.status).toBe(200);
|
|
1672
|
-
expect((dbo.prepare("SELECT yanked FROM market_release WHERE product = ? AND version = ?").get("demo-solution", "0.1.0") as { yanked: number }).yanked).toBe(1);
|
|
1673
|
-
});
|
|
1674
|
-
|
|
1675
|
-
it("索引读不到(验签失败)→ 200 + 已入库历史 + syncError 人话(观测面不阻断页面)", async () => {
|
|
1676
|
-
const api = createMarketApi(makeEnv());
|
|
1677
|
-
g.http = { user: { id: 1 } };
|
|
1678
|
-
await api.releases.get();
|
|
1679
|
-
expect((last?.data as ReleasePageData).total).toBe(3);
|
|
1680
|
-
|
|
1681
|
-
const brokenFetch = (async (url: string) => {
|
|
1682
|
-
if (String(url).endsWith("shards/1.yaml.sig"))
|
|
1683
|
-
return { ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1684
|
-
return cdnFetch(url);
|
|
1685
|
-
}) as FetchFace;
|
|
1686
|
-
const api2 = createMarketApi(makeEnv({ fetch: brokenFetch }));
|
|
1687
|
-
g.http = { user: { id: 1 } };
|
|
1688
|
-
await api2.releases.get();
|
|
1689
|
-
expect(last?.status).toBe(200);
|
|
1690
|
-
const page = last?.data as ReleasePageData;
|
|
1691
|
-
expect(page.total).toBe(3);
|
|
1692
|
-
expect(page.syncError).toContain("index_sig_missing");
|
|
1326
|
+
const d = last?.data as ReleasePageData;
|
|
1327
|
+
expect(d.total).toBe(3);
|
|
1328
|
+
expect(d.page).toBe(1);
|
|
1329
|
+
expect(d.size).toBe(20);
|
|
1330
|
+
expect(d.list.map(r => `${r.product}@${r.version}`)).toEqual(["demo-solution@0.2.0", "wiki@1.0.0", "demo-solution@0.1.0"]);
|
|
1331
|
+
expect(d.list[0]).toMatchObject({ product: "demo-solution", version: "0.2.0", key_id: "test.publisher", sha256: SHA256, size: ARCHIVE.byteLength, published_at: 3000, published_at_source: "upload", yanked: 1 });
|
|
1332
|
+
// 遗留行(key_id NULL)照常可读——历史是观测面
|
|
1333
|
+
expect(d.list[1]?.key_id).toBeNull();
|
|
1334
|
+
// 直读 DB:零索引出站、零 syncError 字段(退役不留双尸)
|
|
1335
|
+
expect(fetchCalls.length).toBe(0);
|
|
1336
|
+
expect("syncError" in d).toBe(false);
|
|
1693
1337
|
});
|
|
1694
1338
|
|
|
1695
1339
|
it("门与分页:匿名 401;page/limit 透传并按发布时间倒序取页", async () => {
|
|
1340
|
+
for (let i = 1; i <= 5; i++)
|
|
1341
|
+
seedRelease(`pkg-${String(i).padStart(2, "0")}`, "0.1.0", { published_at: 1000 + i });
|
|
1696
1342
|
const api = createMarketApi(makeEnv());
|
|
1697
1343
|
g.http = {};
|
|
1698
1344
|
await api.releases.get();
|
|
1699
1345
|
expect(last?.status).toBe(401);
|
|
1700
|
-
|
|
1701
|
-
g.http = { user: { id: 1 }, query: { page: "2", limit: "1" } };
|
|
1346
|
+
g.http = { user: { id: 1 }, query: { page: "2", limit: "2" } };
|
|
1702
1347
|
await api.releases.get();
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
expect(
|
|
1706
|
-
expect(
|
|
1348
|
+
const d = last?.data as ReleasePageData;
|
|
1349
|
+
expect(d.page).toBe(2);
|
|
1350
|
+
expect(d.size).toBe(2);
|
|
1351
|
+
expect(d.list.map(r => r.product)).toEqual(["pkg-03", "pkg-02"]);
|
|
1707
1352
|
});
|
|
1708
1353
|
});
|