@oj-module/cli 0.2.30 → 0.2.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/manual.md +5 -5
- package/devkit/module-dev-and-cdn-playground.md +1 -1
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +23 -14
- package/src/__tests__/list.test.ts +176 -0
- package/src/__tests__/registry-cli.test.ts +522 -0
- package/src/__tests__/registry-export.test.ts +319 -0
- package/src/__tests__/registry-import.test.ts +336 -0
- package/src/__tests__/registry-sign.test.ts +352 -0
- package/src/cli-output.ts +81 -0
- package/src/cli.ts +298 -39
- package/src/config-cmd.ts +100 -0
- package/src/contract/__tests__/handler-scan.test.ts +74 -2
- package/src/contract/handler-scan.ts +27 -2
- package/src/enable.ts +1 -1
- package/src/exit-codes.ts +33 -0
- package/src/index.ts +5 -9
- package/src/install.ts +120 -38
- package/src/lifecycle/__tests__/authoring.test.ts +6 -5
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
- package/src/lifecycle/__tests__/helpers.ts +10 -6
- package/src/lifecycle/__tests__/install.test.ts +72 -15
- package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
- package/src/lifecycle/__tests__/publish.test.ts +174 -33
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
- package/src/lifecycle/__tests__/sign.test.ts +9 -8
- package/src/lifecycle/__tests__/smoke.test.ts +1 -1
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/lifecycle/sign.ts +8 -7
- package/src/list.ts +146 -0
- package/src/manifest-contract.ts +11 -5
- package/src/market/__tests__/bootstrap.test.ts +4 -1
- package/src/market/__tests__/build-index.test.ts +529 -42
- package/src/market/__tests__/fixture.test.ts +17 -1
- package/src/market/__tests__/publish.test.ts +8 -7
- package/src/market/__tests__/setup.test.ts +4 -4
- package/src/market/__tests__/sharding.test.ts +90 -0
- package/src/market/__tests__/verify.test.ts +237 -13
- package/src/market/__tests__/yank-ledger.test.ts +60 -0
- package/src/market/bootstrap.ts +8 -3
- package/src/market/build-index.ts +407 -40
- package/src/market/cdn-layout.ts +3 -2
- package/src/market/command.ts +40 -6
- package/src/market/failure-playbook.ts +1 -1
- package/src/market/fixture.ts +20 -3
- package/src/market/index-builder.ts +120 -47
- package/src/market/publish.ts +3 -1
- package/src/market/setup.ts +3 -3
- package/src/market/sharding.ts +94 -0
- package/src/market/trial.ts +1 -1
- package/src/market/verify.ts +220 -53
- package/src/market/walkthrough.ts +7 -6
- package/src/market/yank-ledger.ts +91 -0
- package/src/publish.ts +250 -38
- package/src/registry-export.ts +405 -0
- package/src/registry-import.ts +410 -0
- package/src/registry-sidecar.ts +84 -0
- package/src/registry-sign.ts +322 -0
- package/src/registry-source.ts +192 -0
- package/src/sign.ts +13 -10
- package/src/site.ts +4 -4
- package/src/trust.ts +1 -1
- package/src/uninstall.ts +14 -2
- package/src/usage.ts +130 -25
- package/src/view.ts +94 -0
- package/templates/cdn/README.md +35 -5
- package/templates/config.yaml +46 -96
- package/templates/init.include.yaml +1 -1
- package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
- package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
- package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
- package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
- package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
- package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
- package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
- package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
- package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
- package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
- package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
- package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
- package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
- package/templates/src/_platform/api/manifest.yaml +1 -0
- package/templates/src/_platform/api/market/api.ts +362 -99
- package/templates/src/_platform/api/market/index-source.ts +112 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
- package/templates/src/_platform/api/schema.yaml +8 -0
- package/templates/src/_platform/api/solutions/api.ts +8 -3
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
- package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
- package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
- package/templates/src/oj-market/web/locales/en-US.json +1 -0
- package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
- package/templates/src/oj-market/web/types.ts +3 -1
- package/templates/tests/lifecycle/installer.spec.ts +24 -0
- package/templates/tests/lifecycle/market-api.spec.ts +641 -85
- package/templates/tests/lifecycle/preflight.spec.ts +52 -0
- package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
- package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
- package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
- package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
- package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
- package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
- package/templates/tests/rbac-solution-perms.spec.ts +41 -0
- package/vendor/host-versions.json +3 -3
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// BDD:`ojm registry export`(REG-R11 离线包的产出端)。
|
|
2
|
+
//
|
|
3
|
+
// 这条命令的存在理由:离线包的**消费端**(`ojm registry import`)早就有,产出端一直靠手工拼——
|
|
4
|
+
// public 站点能整拷 `<cdn>/registry/`,private 站点(REG-R8 的 private 载体)没有那个静态目录,
|
|
5
|
+
// 只能照格式一个个 curl。所以用例盯四件事:
|
|
6
|
+
// ① 产物与 import 逐字对齐:跑完 export 直接把目录喂给 import,能过验签(否则「导出的包自己导不进去」);
|
|
7
|
+
// ② 三条自洽校验各拦一次:正文与侧车不符 / 分片代数与钉值不符 / 包体 sha 或长度与索引不符;
|
|
8
|
+
// ③ 失败不留半份包:暂存目录整体换位,失败后 `<out>/registry/` 不存在、隐藏暂存也不残留;
|
|
9
|
+
// ④ `--dry-run` 只验索引、不下载包体、零落盘。
|
|
10
|
+
import type { KeyPair } from "../lifecycle/keygen";
|
|
11
|
+
import { Buffer } from "node:buffer";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
import { stringify as yamlStringify } from "yaml";
|
|
18
|
+
import { buildCli, localizeCacError, reportCliError } from "../cli";
|
|
19
|
+
import { generateKeyPair } from "../lifecycle/keygen";
|
|
20
|
+
import { signCanonical } from "../lifecycle/sign";
|
|
21
|
+
import { indexCanonical, revocationCanonical } from "../publish";
|
|
22
|
+
import { registryExportCommand, RegistryExportError } from "../registry-export";
|
|
23
|
+
import { registryImportCommand } from "../registry-import";
|
|
24
|
+
|
|
25
|
+
let tmpRoot: string;
|
|
26
|
+
let cwd: string;
|
|
27
|
+
let tmpHome: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ojm-registry-export-"));
|
|
31
|
+
cwd = fs.mkdtempSync(path.join(tmpRoot, "proj-"));
|
|
32
|
+
tmpHome = fs.mkdtempSync(path.join(tmpRoot, "home-"));
|
|
33
|
+
vi.spyOn(os, "homedir").mockReturnValue(tmpHome);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
vi.unstubAllGlobals();
|
|
39
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const SITE = "https://reg.example.com";
|
|
43
|
+
const API = `${SITE}/api/oj-registry/cdn`;
|
|
44
|
+
|
|
45
|
+
function sha256Hex(bytes: Uint8Array): string {
|
|
46
|
+
return createHash("sha256").update(Buffer.from(bytes)).digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 造一份 detached `.sig` 六字段(alg/key_id/public_key/value/canon/seq) */
|
|
50
|
+
function makeSig(key: KeyPair, seq: number, body: string, kind: "catalog" | "shard" | "revocation"): string {
|
|
51
|
+
const canon = kind === "revocation" ? revocationCanonical(seq, body) : indexCanonical(kind, seq, body);
|
|
52
|
+
return JSON.stringify({
|
|
53
|
+
alg: "Ed25519",
|
|
54
|
+
key_id: "registry.index",
|
|
55
|
+
public_key: key.publicKeyHex,
|
|
56
|
+
value: signCanonical(key.privateKeyPem, canon),
|
|
57
|
+
canon: "v2",
|
|
58
|
+
seq,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface Route { status?: number, body?: string | Uint8Array }
|
|
63
|
+
|
|
64
|
+
interface FixtureOpts {
|
|
65
|
+
key: KeyPair
|
|
66
|
+
/** 索引里的版本(默认一个 acme-demo@1.0.0) */
|
|
67
|
+
versions?: Array<{ slug: string, version: string }>
|
|
68
|
+
/** 分片正文里声明的 sha256(不给 = 真实摘要;给了就制造「摘要不符」) */
|
|
69
|
+
declaredSha?: string
|
|
70
|
+
/** 分片签名代次(默认 = catalog 钉值) */
|
|
71
|
+
shardSeq?: number
|
|
72
|
+
catalogSeq?: number
|
|
73
|
+
/** catalog 钉的分片代次(默认 2;与 shardSeq 不同即制造「半代索引」) */
|
|
74
|
+
catalogPin?: number
|
|
75
|
+
/** 先签后改正文,制造「正文与侧车不符」 */
|
|
76
|
+
tamperShard?: boolean
|
|
77
|
+
/** 不给则吊销签名返回 404(模拟「站点还没签过名」) */
|
|
78
|
+
revocationSigned?: boolean
|
|
79
|
+
/** 覆盖任意路由(后写优先) */
|
|
80
|
+
overrides?: Record<string, Route>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface Fixture {
|
|
84
|
+
fetchFn: typeof fetch
|
|
85
|
+
calls: string[]
|
|
86
|
+
/** 每个包体的真实字节(断言落盘逐字节一致) */
|
|
87
|
+
bodies: Map<string, Uint8Array>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 假 registry:按读面路径给 catalog / 分片 / 吊销清单 / 包体与各自的 .sig */
|
|
91
|
+
function buildRegistry(opts: FixtureOpts): Fixture {
|
|
92
|
+
const catalogSeq = opts.catalogSeq ?? 3;
|
|
93
|
+
const catalogPin = opts.catalogPin ?? 2;
|
|
94
|
+
const shardSeq = opts.shardSeq ?? catalogPin;
|
|
95
|
+
const versions = opts.versions ?? [{ slug: "acme-demo", version: "1.0.0" }];
|
|
96
|
+
const bodies = new Map<string, Uint8Array>();
|
|
97
|
+
const routes = new Map<string, Route>();
|
|
98
|
+
|
|
99
|
+
// 每版本一份假包体;分片正文按索引条目形状生成(archive.file 写错也无所谓:导出按 slug/version 自己拼)
|
|
100
|
+
const bySlug = new Map<string, Array<{ version: string, sha256: string, size: number }>>();
|
|
101
|
+
for (const v of versions) {
|
|
102
|
+
const bytes = new Uint8Array(Buffer.from(`tgz:${v.slug}@${v.version}`));
|
|
103
|
+
bodies.set(`${v.slug}-${v.version}.tgz`, bytes);
|
|
104
|
+
const list = bySlug.get(v.slug) ?? [];
|
|
105
|
+
list.push({ version: v.version, sha256: opts.declaredSha ?? sha256Hex(bytes), size: bytes.byteLength });
|
|
106
|
+
bySlug.set(v.slug, list);
|
|
107
|
+
}
|
|
108
|
+
const solutions = [...bySlug.entries()].map(([slug, list]) => ({
|
|
109
|
+
name: slug,
|
|
110
|
+
latest: list[list.length - 1]!.version,
|
|
111
|
+
versions: list.map(v => ({
|
|
112
|
+
name: slug,
|
|
113
|
+
version: v.version,
|
|
114
|
+
archive: { file: `${slug}-${v.version}.tgz`, sha256: v.sha256, size: v.size },
|
|
115
|
+
})),
|
|
116
|
+
}));
|
|
117
|
+
const shardBody = yamlStringify({ solutions });
|
|
118
|
+
const catalogBody = `seq: ${catalogSeq}\nissued_at: 1700000000\nexpires_at: 1700604800\nshards:\n "1": ${catalogPin}\nsolutions:\n - name: acme-demo\n shards: [1]\n`;
|
|
119
|
+
const revBody = "seq: 0\nrevoked: []\n";
|
|
120
|
+
|
|
121
|
+
routes.set(`${API}/index`, { body: catalogBody });
|
|
122
|
+
routes.set(`${API}/index/signature`, { body: makeSig(opts.key, catalogSeq, catalogBody, "catalog") });
|
|
123
|
+
routes.set(`${API}/shard/1`, { body: opts.tamperShard ? `${shardBody}# tampered` : shardBody });
|
|
124
|
+
routes.set(`${API}/shard/1/signature`, { body: makeSig(opts.key, shardSeq, shardBody, "shard") });
|
|
125
|
+
routes.set(`${API}/revocations`, { body: revBody });
|
|
126
|
+
routes.set(`${API}/revocations/signature`, opts.revocationSigned === false
|
|
127
|
+
? { status: 404, body: "" }
|
|
128
|
+
: { body: makeSig(opts.key, 0, revBody, "revocation") });
|
|
129
|
+
for (const v of versions) {
|
|
130
|
+
const file = `${v.slug}-${v.version}.tgz`;
|
|
131
|
+
const bytes = bodies.get(file)!;
|
|
132
|
+
routes.set(`${API}/package/${v.slug}/${v.version}`, { body: bytes });
|
|
133
|
+
routes.set(`${API}/package/${v.slug}/${v.version}/signature`, { body: "{\"alg\":\"Ed25519\",\"key_id\":\"publisher.key\"}" });
|
|
134
|
+
}
|
|
135
|
+
for (const [k, v] of Object.entries(opts.overrides ?? {}))
|
|
136
|
+
routes.set(k, v);
|
|
137
|
+
|
|
138
|
+
const calls: string[] = [];
|
|
139
|
+
const fetchFn = vi.fn(async (input: string | URL | Request) => {
|
|
140
|
+
const url = String(input);
|
|
141
|
+
calls.push(url);
|
|
142
|
+
const route = routes.get(url);
|
|
143
|
+
if (route === undefined)
|
|
144
|
+
return new Response("not found", { status: 404 });
|
|
145
|
+
return new Response((route.body ?? "") as BodyInit, { status: route.status ?? 200 });
|
|
146
|
+
}) as unknown as typeof fetch;
|
|
147
|
+
return { fetchFn, calls, bodies };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 写一份 active 索引 key 集合 JSON(给 import 回环用) */
|
|
151
|
+
function writeKeys(key: KeyPair): string {
|
|
152
|
+
const file = path.join(cwd, "keys.json");
|
|
153
|
+
fs.writeFileSync(file, JSON.stringify([{ key_id: "registry.index", public_key: key.publicKeyHex, status: "active" }]));
|
|
154
|
+
return file;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function exportErr(opts: Parameters<typeof registryExportCommand>[0]): Promise<RegistryExportError> {
|
|
158
|
+
try {
|
|
159
|
+
await registryExportCommand(opts);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
expect(error).toBeInstanceOf(RegistryExportError);
|
|
163
|
+
return error as RegistryExportError;
|
|
164
|
+
}
|
|
165
|
+
throw new Error("应当失败");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe("registry export:产物结构与 import 回环", () => {
|
|
169
|
+
it("scenario: 导出 → 产物逐字节等于源、目录结构就是 import 吃的形态,直接回环导入能过验签", async () => {
|
|
170
|
+
const key = generateKeyPair();
|
|
171
|
+
const reg = buildRegistry({ key });
|
|
172
|
+
const out = path.join(tmpRoot, "pkg");
|
|
173
|
+
// 预置旧产物:导出成功后必须整体换位,旧文件不许残留
|
|
174
|
+
fs.mkdirSync(path.join(out, "registry"), { recursive: true });
|
|
175
|
+
fs.writeFileSync(path.join(out, "registry", "stale.yaml"), "old\n");
|
|
176
|
+
|
|
177
|
+
const result = await registryExportCommand({ siteUrl: SITE, out, fetchFn: reg.fetchFn, cwd });
|
|
178
|
+
|
|
179
|
+
expect(result).toMatchObject({ catalogSeq: 3, revocationSeq: 0, shards: [1], packages: 1, verified: 3, dryRun: false });
|
|
180
|
+
const target = path.join(out, "registry");
|
|
181
|
+
for (const f of ["catalog.yaml", "catalog.yaml.sig", "shards/1.yaml", "shards/1.yaml.sig", "revocations.yaml", "revocations.yaml.sig", "acme-demo-1.0.0.tgz", "acme-demo-1.0.0.tgz.sig"])
|
|
182
|
+
expect(fs.existsSync(path.join(target, f)), `应落盘 ${f}`).toBe(true);
|
|
183
|
+
expect(fs.existsSync(path.join(target, "stale.yaml")), "旧产物应被整体换掉").toBe(false);
|
|
184
|
+
|
|
185
|
+
// 包体逐字节等于源(离线包的价值就在这里,重渲染/改行尾都会让 sha 对不上)
|
|
186
|
+
expect(new Uint8Array(fs.readFileSync(path.join(target, "acme-demo-1.0.0.tgz")))).toEqual(reg.bodies.get("acme-demo-1.0.0.tgz"));
|
|
187
|
+
// source.json 带 origin:import 用它给 seq 账本分桶(而不是 `offline:<绝对路径>`)
|
|
188
|
+
expect(JSON.parse(fs.readFileSync(path.join(out, "source.json"), "utf8"))).toMatchObject({ origin: SITE, catalog_seq: 3, revocation_seq: 0, shards: [1], packages: 1 });
|
|
189
|
+
|
|
190
|
+
// 回环:import 直接吃这个目录(不给 --registry,靠 source.json 的 origin)
|
|
191
|
+
const restored = path.join(tmpRoot, "restored");
|
|
192
|
+
const imported = registryImportCommand({ dir: out, out: restored, keys: writeKeys(key), cwd, home: tmpHome });
|
|
193
|
+
expect(imported.ok).toBe(true);
|
|
194
|
+
expect(imported.verified).toBe(3);
|
|
195
|
+
expect(fs.existsSync(path.join(restored, "acme-demo-1.0.0.tgz"))).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("scenario: 包体 sha 与索引声明不符 → package_sha_mismatch,且不在磁盘留半份包", async () => {
|
|
199
|
+
const key = generateKeyPair();
|
|
200
|
+
const reg = buildRegistry({ key, declaredSha: "0".repeat(64) });
|
|
201
|
+
const out = path.join(tmpRoot, "pkg");
|
|
202
|
+
const error = await exportErr({ siteUrl: SITE, out, fetchFn: reg.fetchFn, cwd });
|
|
203
|
+
expect(error.code).toBe("package_sha_mismatch");
|
|
204
|
+
expect(fs.existsSync(path.join(out, "registry"))).toBe(false);
|
|
205
|
+
expect(fs.existsSync(path.join(out, "source.json"))).toBe(false);
|
|
206
|
+
// 隐藏暂存目录也要清掉(否则下次导出会捡到上次的残骸)
|
|
207
|
+
expect(fs.readdirSync(out).filter(n => n.startsWith(".ojm-registry-export-"))).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("scenario: 分片签名代数 ≠ 总目录钉值 → index_generation_mismatch(源正在重建,抓到的是半代索引)", async () => {
|
|
211
|
+
const key = generateKeyPair();
|
|
212
|
+
const reg = buildRegistry({ key, shardSeq: 9 });
|
|
213
|
+
const error = await exportErr({ siteUrl: SITE, out: path.join(tmpRoot, "pkg"), fetchFn: reg.fetchFn, cwd });
|
|
214
|
+
expect(error.code).toBe("index_generation_mismatch");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("scenario: 正文被改过(侧车对不上)→ index_sig_mismatch", async () => {
|
|
218
|
+
const key = generateKeyPair();
|
|
219
|
+
const reg = buildRegistry({ key, tamperShard: true });
|
|
220
|
+
const error = await exportErr({ siteUrl: SITE, out: path.join(tmpRoot, "pkg"), fetchFn: reg.fetchFn, cwd });
|
|
221
|
+
expect(error.code).toBe("index_sig_mismatch");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("scenario: 站点没给吊销清单签过名(404)→ revocations_sig_missing,不产「看起来没吊销」的包", async () => {
|
|
225
|
+
const key = generateKeyPair();
|
|
226
|
+
const reg = buildRegistry({ key, revocationSigned: false });
|
|
227
|
+
const error = await exportErr({ siteUrl: SITE, out: path.join(tmpRoot, "pkg"), fetchFn: reg.fetchFn, cwd });
|
|
228
|
+
expect(error.code).toBe("revocations_sig_missing");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("scenario: 读面不可用(503)→ export_fetch_failed,消息里带地址", async () => {
|
|
232
|
+
const key = generateKeyPair();
|
|
233
|
+
const reg = buildRegistry({ key, overrides: { [`${API}/index`]: { status: 503, body: "down" } } });
|
|
234
|
+
const error = await exportErr({ siteUrl: SITE, out: path.join(tmpRoot, "pkg"), fetchFn: reg.fetchFn, cwd });
|
|
235
|
+
expect(error.code).toBe("export_fetch_failed");
|
|
236
|
+
expect(error.message).toContain(`${API}/index`);
|
|
237
|
+
expect(error.message).toContain("503");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("scenario: 包体缺 .sig 侧车 → export_fetch_failed(缺它安装侧无法验包)", async () => {
|
|
241
|
+
const key = generateKeyPair();
|
|
242
|
+
const reg = buildRegistry({ key, overrides: { [`${API}/package/acme-demo/1.0.0/signature`]: { status: 404, body: "" } } });
|
|
243
|
+
const error = await exportErr({ siteUrl: SITE, out: path.join(tmpRoot, "pkg"), fetchFn: reg.fetchFn, cwd });
|
|
244
|
+
expect(error.code).toBe("export_fetch_failed");
|
|
245
|
+
expect(error.message).toContain("签名");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("scenario: --dry-run → 索引层全验、列全计划文件,但不下载任何包体、零落盘", async () => {
|
|
249
|
+
const key = generateKeyPair();
|
|
250
|
+
const reg = buildRegistry({ key });
|
|
251
|
+
const out = path.join(tmpRoot, "dry");
|
|
252
|
+
const result = await registryExportCommand({ siteUrl: SITE, out, fetchFn: reg.fetchFn, cwd, dryRun: true });
|
|
253
|
+
|
|
254
|
+
expect(result.dryRun).toBe(true);
|
|
255
|
+
expect(result.written).toContain("registry/acme-demo-1.0.0.tgz");
|
|
256
|
+
expect(result.written).toContain("source.json");
|
|
257
|
+
expect(result.packages).toBe(1);
|
|
258
|
+
// 一次包体请求都没发(dry-run 的全部价值:不消耗流量)
|
|
259
|
+
expect(reg.calls.filter(u => u.includes("/package/"))).toEqual([]);
|
|
260
|
+
expect(fs.existsSync(out)).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("registry export:CLI 接线与信封", () => {
|
|
265
|
+
interface RunResult { out: string[], err: string[], exitCode: number | undefined }
|
|
266
|
+
async function run(argv: string[]): Promise<RunResult> {
|
|
267
|
+
const out: string[] = [];
|
|
268
|
+
const err: string[] = [];
|
|
269
|
+
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
|
270
|
+
out.push(args.map(String).join(" "));
|
|
271
|
+
});
|
|
272
|
+
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
273
|
+
err.push(args.map(String).join(" "));
|
|
274
|
+
});
|
|
275
|
+
const exitSpy = vi.fn();
|
|
276
|
+
const exit = exitSpy as unknown as (code: number) => never;
|
|
277
|
+
const cli = buildCli(cwd, "zh-CN", { exit, argv });
|
|
278
|
+
try {
|
|
279
|
+
cli.parse(["node", "ojm", ...argv], { run: true });
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
reportCliError(localizeCacError(error, "zh-CN"), exit, argv);
|
|
283
|
+
}
|
|
284
|
+
await vi.waitUntil(() => out.length > 0 || err.length > 0 || exitSpy.mock.calls.length > 0, { timeout: 3000, interval: 10 });
|
|
285
|
+
return { out, err, exitCode: exitSpy.mock.calls[0]?.[0] as number | undefined };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
it("scenario: --json 成功 → stdout 只有一行信封,代次与文件清单在 data 里", async () => {
|
|
289
|
+
const key = generateKeyPair();
|
|
290
|
+
const reg = buildRegistry({ key });
|
|
291
|
+
vi.stubGlobal("fetch", reg.fetchFn);
|
|
292
|
+
const out = path.join(tmpRoot, "pkg");
|
|
293
|
+
const r = await run(["registry", "export", "--site-url", SITE, "--out", out, "--json"]);
|
|
294
|
+
|
|
295
|
+
expect(r.exitCode).toBeUndefined();
|
|
296
|
+
expect(r.out).toHaveLength(1);
|
|
297
|
+
const env = JSON.parse(r.out[0]!) as { ok: boolean, command: string, code: string, data: { catalogSeq: number, revocationSeq: number, packages: number, written: string[] } };
|
|
298
|
+
expect(env).toMatchObject({ ok: true, command: "registry export", code: "ok" });
|
|
299
|
+
expect(env.data).toMatchObject({ catalogSeq: 3, revocationSeq: 0, packages: 1 });
|
|
300
|
+
expect(env.data.written).toContain("registry/catalog.yaml");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("scenario: 摘要不符 → exit 1,信封 code = package_sha_mismatch(CI 靠码分支)", async () => {
|
|
304
|
+
const key = generateKeyPair();
|
|
305
|
+
const reg = buildRegistry({ key, declaredSha: "0".repeat(64) });
|
|
306
|
+
vi.stubGlobal("fetch", reg.fetchFn);
|
|
307
|
+
const r = await run(["registry", "export", "--site-url", SITE, "--out", path.join(tmpRoot, "pkg"), "--json"]);
|
|
308
|
+
|
|
309
|
+
expect(r.exitCode).toBe(1);
|
|
310
|
+
const env = JSON.parse(r.out[0]!) as { ok: boolean, code: string, data: unknown };
|
|
311
|
+
expect(env).toMatchObject({ ok: false, code: "package_sha_mismatch", data: null });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("scenario: 缺 --site-url → 用法错误(2,不是业务失败 1)", async () => {
|
|
315
|
+
const r = await run(["registry", "export", "--out", path.join(tmpRoot, "pkg")]);
|
|
316
|
+
expect(r.exitCode).toBe(2);
|
|
317
|
+
expect(r.err.join("\n")).toMatch(/--site-url/);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// BDD:`ojm registry import`(REG-R11 离线导入,运维侧)。
|
|
2
|
+
//
|
|
3
|
+
// 主场景是「源挂了还能救」——内容源(CDN/registry 读面)大概率已离线,但站点 admin 可直登这台机器跑命令。
|
|
4
|
+
// 所以 import 全程不触网:读 `<dir>/registry/`、复用站点已登记的 active 索引 key 验签、落盘(或 --dry-run)。
|
|
5
|
+
// 用例盯三件事:
|
|
6
|
+
// ① 验签:catalog / 各分片 / revocations 的正文 + detached `.sig`,复用 `verifyCanonical`(与线上 REG-CS1 同口径);
|
|
7
|
+
// ② 吊销 seq 护栏:本地账本比代次,回退/缩水/同代清空都拒(`revocations_not_monotonic`),等价线上 `guardAndRecordRevocationSeq`;
|
|
8
|
+
// ③ 落盘:public 用 --site 落到 `<cdn>/registry/`,private 用 --out;`--dry-run` 零写入。
|
|
9
|
+
// 判别码与线上 S5-3 同词汇,便于排障一眼归并。
|
|
10
|
+
import type { KeyPair } from "../lifecycle/keygen";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
import { buildCli, localizeCacError, reportCliError } from "../cli";
|
|
16
|
+
import { generateKeyPair } from "../lifecycle/keygen";
|
|
17
|
+
import { signCanonical } from "../lifecycle/sign";
|
|
18
|
+
import { indexCanonical, revocationCanonical } from "../publish";
|
|
19
|
+
import { REGISTRY_SUBDIR, registryImportCommand, RegistryImportError } from "../registry-import";
|
|
20
|
+
|
|
21
|
+
let tmpRoot: string;
|
|
22
|
+
let cwd: string;
|
|
23
|
+
let tmpHome: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ojm-registry-import-"));
|
|
27
|
+
cwd = fs.mkdtempSync(path.join(tmpRoot, "proj-"));
|
|
28
|
+
tmpHome = fs.mkdtempSync(path.join(tmpRoot, "home-"));
|
|
29
|
+
vi.spyOn(os, "homedir").mockReturnValue(tmpHome);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** 造一个 detached `.sig` 六字段(alg/key_id/public_key/value/canon/seq),value 用真私钥签 canon */
|
|
38
|
+
function makeSig(key: KeyPair, seq: number, body: string, kind: "catalog" | "shard" | "revocation"): string {
|
|
39
|
+
const canon = kind === "revocation" ? revocationCanonical(seq, body) : indexCanonical(kind, seq, body);
|
|
40
|
+
return JSON.stringify({
|
|
41
|
+
alg: "Ed25519",
|
|
42
|
+
key_id: "registry.index",
|
|
43
|
+
public_key: key.publicKeyHex,
|
|
44
|
+
value: signCanonical(key.privateKeyPem, canon),
|
|
45
|
+
canon: "v2",
|
|
46
|
+
seq,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 写一份 active 索引 key 集合 JSON(只含登记为 active 的 key) */
|
|
51
|
+
function writeKeys(key: KeyPair, name = "keys.json"): string {
|
|
52
|
+
const file = path.join(cwd, name);
|
|
53
|
+
fs.writeFileSync(file, JSON.stringify([{ key_id: "registry.index", public_key: key.publicKeyHex, status: "active" }]));
|
|
54
|
+
return file;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 在本地信任库预置一份吊销 seq 账本(模拟「本源已见过更高代次」) */
|
|
58
|
+
function writeLedger(origin: string, maxSeq: number, contentSha = "deadbeef"): void {
|
|
59
|
+
const dir = path.join(tmpHome, ".ojm", "registry", origin);
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
61
|
+
fs.writeFileSync(path.join(dir, "revocation-seq.json"), `${JSON.stringify({ max_seq: maxSeq, content_sha: contentSha })}\n`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface BuildOpts {
|
|
65
|
+
key: KeyPair
|
|
66
|
+
/** 给则包含 revocations.yaml(签在该 seq);不给则无吊销清单 */
|
|
67
|
+
revocationSeq?: number
|
|
68
|
+
shardNo?: number
|
|
69
|
+
shardGen?: number
|
|
70
|
+
catalogSeq?: number
|
|
71
|
+
catalogBody?: string
|
|
72
|
+
shardBody?: string
|
|
73
|
+
/** 先签后改正文,制造「签名与正文不符」 */
|
|
74
|
+
tamperCatalog?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 造一份合法离线包目录(<dir>/registry/...),返回 <dir> */
|
|
78
|
+
function buildPackage(opts: BuildOpts): string {
|
|
79
|
+
const dir = fs.mkdtempSync(path.join(tmpRoot, "pkg-"));
|
|
80
|
+
const reg = path.join(dir, REGISTRY_SUBDIR);
|
|
81
|
+
fs.mkdirSync(path.join(reg, "shards"), { recursive: true });
|
|
82
|
+
|
|
83
|
+
const shardNo = opts.shardNo ?? 1;
|
|
84
|
+
const shardGen = opts.shardGen ?? 1;
|
|
85
|
+
const catalogBody = opts.catalogBody ?? `seq: ${opts.catalogSeq ?? 1}
|
|
86
|
+
shards:
|
|
87
|
+
"${shardNo}": ${shardGen}
|
|
88
|
+
`;
|
|
89
|
+
const shardBody = opts.shardBody ?? "solutions: []\n";
|
|
90
|
+
|
|
91
|
+
fs.writeFileSync(path.join(reg, "catalog.yaml"), catalogBody);
|
|
92
|
+
fs.writeFileSync(path.join(reg, "catalog.yaml.sig"), makeSig(opts.key, opts.catalogSeq ?? 1, catalogBody, "catalog"));
|
|
93
|
+
|
|
94
|
+
fs.writeFileSync(path.join(reg, "shards", `${shardNo}.yaml`), shardBody);
|
|
95
|
+
fs.writeFileSync(path.join(reg, "shards", `${shardNo}.yaml.sig`), makeSig(opts.key, shardGen, shardBody, "shard"));
|
|
96
|
+
|
|
97
|
+
if (opts.revocationSeq !== undefined) {
|
|
98
|
+
const rev = `seq: ${opts.revocationSeq}
|
|
99
|
+
revoked: []
|
|
100
|
+
`;
|
|
101
|
+
fs.writeFileSync(path.join(reg, "revocations.yaml"), rev);
|
|
102
|
+
fs.writeFileSync(path.join(reg, "revocations.yaml.sig"), makeSig(opts.key, opts.revocationSeq, rev, "revocation"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (opts.tamperCatalog)
|
|
106
|
+
fs.writeFileSync(path.join(reg, "catalog.yaml"), `${catalogBody}# tampered`);
|
|
107
|
+
|
|
108
|
+
return dir;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 一个 --site 用的站点数据根,可选是否带 /cdn 挂载 */
|
|
112
|
+
function buildSite(withMount: boolean): string {
|
|
113
|
+
const site = fs.mkdtempSync(path.join(tmpRoot, "site-"));
|
|
114
|
+
const web = fs.mkdtempSync(path.join(site, "web"));
|
|
115
|
+
const cfg = withMount
|
|
116
|
+
? `mounts:\n - prefix: /cdn\n web: ${web}\n`
|
|
117
|
+
: `mounts:\n - prefix: /api\n web: ${web}\n`;
|
|
118
|
+
fs.writeFileSync(path.join(site, "config.yaml"), cfg);
|
|
119
|
+
return site;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("registry import:三步校验(验签 + seq 护栏 + 落盘)", () => {
|
|
123
|
+
it("scenario: 合法包 → 全验签通过,原样落盘到 --out,本地 seq 账本推进", () => {
|
|
124
|
+
const key = generateKeyPair();
|
|
125
|
+
const dir = buildPackage({ key, revocationSeq: 5 });
|
|
126
|
+
const out = fs.mkdtempSync(path.join(tmpRoot, "out-"));
|
|
127
|
+
const result = registryImportCommand({ dir, out, keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome });
|
|
128
|
+
|
|
129
|
+
expect(result.ok).toBe(true);
|
|
130
|
+
expect(result.revocationSeq).toBe(5);
|
|
131
|
+
expect(result.verified).toBe(3); // catalog + 1 片 + revocations
|
|
132
|
+
for (const f of ["catalog.yaml", "catalog.yaml.sig", "shards/1.yaml", "shards/1.yaml.sig", "revocations.yaml", "revocations.yaml.sig"])
|
|
133
|
+
expect(fs.existsSync(path.join(out, f)), `应落盘 ${f}`).toBe(true);
|
|
134
|
+
// 落盘 = 包内原始正文(字节一致,未重渲染)
|
|
135
|
+
expect(fs.readFileSync(path.join(out, "catalog.yaml"), "utf8")).toBe(fs.readFileSync(path.join(dir, REGISTRY_SUBDIR, "catalog.yaml"), "utf8"));
|
|
136
|
+
// 本地 seq 账本推进到 5
|
|
137
|
+
const ledger = JSON.parse(fs.readFileSync(path.join(tmpHome, ".ojm", "registry", "test-origin", "revocation-seq.json"), "utf8"));
|
|
138
|
+
expect(ledger).toEqual({ max_seq: 5, content_sha: expect.any(String) });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("scenario: 篡改总目录正文 → index_sig_mismatch(签名与正文不符,已拒载)", () => {
|
|
142
|
+
const key = generateKeyPair();
|
|
143
|
+
const dir = buildPackage({ key, revocationSeq: 5, tamperCatalog: true });
|
|
144
|
+
try {
|
|
145
|
+
registryImportCommand({ dir, out: fs.mkdtempSync(path.join(tmpRoot, "o-")), keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome });
|
|
146
|
+
throw new Error("应当失败");
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
expect(error).toBeInstanceOf(RegistryImportError);
|
|
150
|
+
expect((error as RegistryImportError).code).toBe("index_sig_mismatch");
|
|
151
|
+
}
|
|
152
|
+
// 篡改后零落盘
|
|
153
|
+
const out = fs.mkdtempSync(path.join(tmpRoot, "o-"));
|
|
154
|
+
expect(() => registryImportCommand({ dir, out, keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome })).toThrow(RegistryImportError);
|
|
155
|
+
expect(fs.existsSync(path.join(out, "catalog.yaml"))).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("scenario: 签名公钥不在 active 集合 → index_key_mismatch(离线无法确认它属于 registry)", () => {
|
|
159
|
+
const signer = generateKeyPair();
|
|
160
|
+
const other = generateKeyPair();
|
|
161
|
+
const dir = buildPackage({ key: signer, revocationSeq: 5 });
|
|
162
|
+
try {
|
|
163
|
+
registryImportCommand({ dir, out: fs.mkdtempSync(path.join(tmpRoot, "o-")), keys: writeKeys(other), registry: "test-origin", cwd, home: tmpHome });
|
|
164
|
+
throw new Error("应当失败");
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
expect(error).toBeInstanceOf(RegistryImportError);
|
|
168
|
+
expect((error as RegistryImportError).code).toBe("index_key_mismatch");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("scenario: 缺总目录 → offline_format(结构破损,已拒载)", () => {
|
|
173
|
+
const key = generateKeyPair();
|
|
174
|
+
const dir = fs.mkdtempSync(path.join(tmpRoot, "pkg-"));
|
|
175
|
+
fs.mkdirSync(path.join(dir, REGISTRY_SUBDIR), { recursive: true }); // 有子目录但无 catalog.yaml
|
|
176
|
+
try {
|
|
177
|
+
registryImportCommand({ dir, out: fs.mkdtempSync(path.join(tmpRoot, "o-")), keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome });
|
|
178
|
+
throw new Error("应当失败");
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
expect(error).toBeInstanceOf(RegistryImportError);
|
|
182
|
+
expect((error as RegistryImportError).code).toBe("offline_format");
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("scenario: --site 但无 /cdn 挂载 → static_mount_missing(registry 不新增 mounts)", () => {
|
|
187
|
+
const key = generateKeyPair();
|
|
188
|
+
const dir = buildPackage({ key, revocationSeq: 5 });
|
|
189
|
+
const site = buildSite(false); // 只有 /api 挂载
|
|
190
|
+
try {
|
|
191
|
+
registryImportCommand({ dir, site, keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome });
|
|
192
|
+
throw new Error("应当失败");
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
expect(error).toBeInstanceOf(RegistryImportError);
|
|
196
|
+
expect((error as RegistryImportError).code).toBe("static_mount_missing");
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("scenario: 既无 --site 也无 --out → 用法错误(private 站点必须显式给 --out)", () => {
|
|
201
|
+
const key = generateKeyPair();
|
|
202
|
+
const dir = buildPackage({ key, revocationSeq: 5 });
|
|
203
|
+
expect(() => registryImportCommand({ dir, keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome }))
|
|
204
|
+
.toThrowError(/--site|--out/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("scenario: --dry-run → 校验通过但零落盘,written 是计划而非实际写入", () => {
|
|
208
|
+
const key = generateKeyPair();
|
|
209
|
+
const dir = buildPackage({ key, revocationSeq: 5 });
|
|
210
|
+
const out = path.join(tmpRoot, "dryrun-out"); // 不预建,dry-run 也不该建
|
|
211
|
+
const result = registryImportCommand({ dir, out, keys: writeKeys(key), registry: "test-origin", dryRun: true, cwd, home: tmpHome });
|
|
212
|
+
expect(result.ok).toBe(true);
|
|
213
|
+
expect(result.written.length).toBe(6); // 计划落 6 个文件
|
|
214
|
+
expect(fs.existsSync(out)).toBe(false); // 零落盘
|
|
215
|
+
// 本地 seq 账本也不推进
|
|
216
|
+
expect(fs.existsSync(path.join(tmpHome, ".ojm", "registry", "test-origin", "revocation-seq.json"))).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("registry import:吊销 seq 护栏(防黑名单回退)", () => {
|
|
221
|
+
it("scenario: 本地账本已知更高代次 → revocations_not_monotonic(清单被回退,已拒载)", () => {
|
|
222
|
+
const key = generateKeyPair();
|
|
223
|
+
const dir = buildPackage({ key, revocationSeq: 5 });
|
|
224
|
+
writeLedger("test-origin", 99); // 本源已见过第 99 代
|
|
225
|
+
try {
|
|
226
|
+
registryImportCommand({ dir, out: fs.mkdtempSync(path.join(tmpRoot, "o-")), keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome });
|
|
227
|
+
throw new Error("应当失败");
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
expect(error).toBeInstanceOf(RegistryImportError);
|
|
231
|
+
expect((error as RegistryImportError).code).toBe("revocations_not_monotonic");
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("scenario: 同代但正文指纹变了 → revocations_not_monotonic(同代清空黑名单被拦)", () => {
|
|
236
|
+
const key = generateKeyPair();
|
|
237
|
+
const dir = buildPackage({ key, revocationSeq: 5 });
|
|
238
|
+
writeLedger("test-origin", 5, "a-different-sha"); // 同代 5 但内容指纹不同
|
|
239
|
+
try {
|
|
240
|
+
registryImportCommand({ dir, out: fs.mkdtempSync(path.join(tmpRoot, "o-")), keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome });
|
|
241
|
+
throw new Error("应当失败");
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
expect(error).toBeInstanceOf(RegistryImportError);
|
|
245
|
+
expect((error as RegistryImportError).code).toBe("revocations_not_monotonic");
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("scenario: 包不含吊销清单,但本地账本记过 → revocations_not_monotonic(等于把黑名单清空)", () => {
|
|
250
|
+
const key = generateKeyPair();
|
|
251
|
+
const dir = buildPackage({ key }); // 无 revocationSeq ⇒ 无 revocations.yaml
|
|
252
|
+
writeLedger("test-origin", 3); // 本源已记录过第 3 代
|
|
253
|
+
try {
|
|
254
|
+
registryImportCommand({ dir, out: fs.mkdtempSync(path.join(tmpRoot, "o-")), keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome });
|
|
255
|
+
throw new Error("应当失败");
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
expect(error).toBeInstanceOf(RegistryImportError);
|
|
259
|
+
expect((error as RegistryImportError).code).toBe("revocations_not_monotonic");
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("scenario: 本地账本为空或更低代次 → 放行并推进账本", () => {
|
|
264
|
+
const key = generateKeyPair();
|
|
265
|
+
const dir = buildPackage({ key, revocationSeq: 7 });
|
|
266
|
+
writeLedger("test-origin", 3); // 更低代次,可前进
|
|
267
|
+
const out = fs.mkdtempSync(path.join(tmpRoot, "out-"));
|
|
268
|
+
const result = registryImportCommand({ dir, out, keys: writeKeys(key), registry: "test-origin", cwd, home: tmpHome });
|
|
269
|
+
expect(result.ok).toBe(true);
|
|
270
|
+
expect(result.revocationSeq).toBe(7);
|
|
271
|
+
const ledger = JSON.parse(fs.readFileSync(path.join(tmpHome, ".ojm", "registry", "test-origin", "revocation-seq.json"), "utf8"));
|
|
272
|
+
expect(ledger.max_seq).toBe(7);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("registry import:CLI 接线与信封", () => {
|
|
277
|
+
interface RunResult { out: string[], err: string[], exitCode: number | undefined }
|
|
278
|
+
async function run(argv: string[]): Promise<RunResult> {
|
|
279
|
+
const out: string[] = [];
|
|
280
|
+
const err: string[] = [];
|
|
281
|
+
const outSpy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
|
282
|
+
out.push(args.map(String).join(" "));
|
|
283
|
+
});
|
|
284
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
285
|
+
err.push(args.map(String).join(" "));
|
|
286
|
+
});
|
|
287
|
+
const exitSpy = vi.fn();
|
|
288
|
+
const exit = exitSpy as unknown as (code: number) => never;
|
|
289
|
+
const cli = buildCli(cwd, "zh-CN", { exit, argv });
|
|
290
|
+
try {
|
|
291
|
+
cli.parse(["node", "ojm", ...argv], { run: true });
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
reportCliError(localizeCacError(error, "zh-CN"), exit, argv);
|
|
295
|
+
}
|
|
296
|
+
await vi.waitUntil(() => out.length > 0 || err.length > 0 || exitSpy.mock.calls.length > 0, { timeout: 3000, interval: 10 });
|
|
297
|
+
outSpy.mockRestore();
|
|
298
|
+
errSpy.mockRestore();
|
|
299
|
+
return { out, err, exitCode: exitSpy.mock.calls[0]?.[0] as number | undefined };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
it("scenario: 合法包 --json 成功 → stdout 只有一行信封,written 在 data 里", async () => {
|
|
303
|
+
const key = generateKeyPair();
|
|
304
|
+
const dir = buildPackage({ key, revocationSeq: 5 });
|
|
305
|
+
const out = fs.mkdtempSync(path.join(tmpRoot, "out-"));
|
|
306
|
+
const keys = writeKeys(key);
|
|
307
|
+
const r = await run(["registry", "import", dir, "--out", out, "--keys", keys, "--registry", "test-origin", "--json"]);
|
|
308
|
+
expect(r.exitCode).toBeUndefined();
|
|
309
|
+
expect(r.out).toHaveLength(1);
|
|
310
|
+
const env = JSON.parse(r.out[0]!) as { ok: boolean, command: string, code: string, data: { verified: number, written: string[], revocationSeq: number | null, dryRun: boolean } };
|
|
311
|
+
expect(env.ok).toBe(true);
|
|
312
|
+
expect(env.command).toBe("registry import");
|
|
313
|
+
expect(env.code).toBe("ok");
|
|
314
|
+
expect(env.data.verified).toBe(3);
|
|
315
|
+
expect(env.data.revocationSeq).toBe(5);
|
|
316
|
+
expect(env.data.written.sort()).toEqual(["catalog.yaml", "catalog.yaml.sig", "revocations.yaml", "revocations.yaml.sig", "shards/1.yaml", "shards/1.yaml.sig"].sort());
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("scenario: 公钥不符 → exit 1,信封 code = index_key_mismatch(CI 靠码分支,不解析人话)", async () => {
|
|
320
|
+
const signer = generateKeyPair();
|
|
321
|
+
const other = generateKeyPair();
|
|
322
|
+
const dir = buildPackage({ key: signer, revocationSeq: 5 });
|
|
323
|
+
const out = fs.mkdtempSync(path.join(tmpRoot, "out-"));
|
|
324
|
+
const keys = writeKeys(other);
|
|
325
|
+
const r = await run(["registry", "import", dir, "--out", out, "--keys", keys, "--registry", "test-origin", "--json"]);
|
|
326
|
+
expect(r.exitCode).toBe(1);
|
|
327
|
+
expect(r.out).toHaveLength(1);
|
|
328
|
+
const env = JSON.parse(r.out[0]!) as { ok: boolean, code: string, data: unknown };
|
|
329
|
+
expect(env).toMatchObject({ ok: false, code: "index_key_mismatch", data: null });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("scenario: 未知子命令 → 用法错误(拼错是 2,不是业务失败 1)", async () => {
|
|
333
|
+
const typo = await run(["registry", "improt", "/tmp/nope", "--out", "/tmp/o"]);
|
|
334
|
+
expect(typo.exitCode).toBe(2);
|
|
335
|
+
});
|
|
336
|
+
});
|