@oj-module/cli 0.2.2 → 0.2.6
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 +14 -3
- package/devkit/SKILL.md +16 -4
- package/devkit/framework-dev.md +8 -6
- package/devkit/manual.md +243 -23
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +143 -8
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/args.ts +67 -0
- package/src/build.ts +11 -3
- package/src/bundle.ts +197 -0
- package/src/contract/__tests__/handler-scan.test.ts +82 -0
- package/src/contract/check.ts +2 -1
- package/src/contract/handler-scan.ts +32 -0
- package/src/deterministic-tar.ts +120 -0
- package/src/dev.ts +4 -2
- package/src/enable.ts +28 -0
- package/src/index.ts +63 -2
- package/src/info.ts +3 -2
- package/src/init.ts +189 -14
- package/src/install.ts +268 -0
- package/src/keygen.ts +88 -0
- package/src/layout.ts +31 -0
- package/src/lifecycle/__tests__/authoring.test.ts +109 -0
- package/src/lifecycle/__tests__/bundle.test.ts +150 -0
- package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
- package/src/lifecycle/__tests__/fixtures/README.md +59 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
- package/src/lifecycle/__tests__/helpers.ts +78 -0
- package/src/lifecycle/__tests__/install.test.ts +514 -0
- package/src/lifecycle/__tests__/keygen.test.ts +61 -0
- package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
- package/src/lifecycle/__tests__/publish.test.ts +80 -0
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
- package/src/lifecycle/__tests__/sign.test.ts +131 -0
- package/src/lifecycle/__tests__/smoke.test.ts +61 -0
- package/src/lifecycle/__tests__/trust.test.ts +177 -0
- package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
- package/src/lifecycle/keygen.ts +40 -0
- package/src/lifecycle/sign.ts +74 -0
- package/src/manifest-contract.ts +696 -0
- package/src/oj-cert.ts +19 -0
- package/src/oj-cmd.ts +3 -3
- package/src/preview.ts +2 -2
- package/src/publish.ts +150 -0
- package/src/sign.ts +128 -0
- package/src/site.ts +146 -0
- package/src/trust.ts +116 -0
- package/src/uninstall.ts +29 -0
- package/src/usage.ts +35 -1
- package/templates/README.md +1 -1
- package/templates/api/config.yaml +47 -0
- package/templates/api/seed.mysql.sql +1 -1
- package/templates/api/seed.pg.sql +1 -1
- package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
- package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
- package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
- package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
- package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
- package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
- package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
- package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
- package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
- package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
- package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
- package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
- package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
- package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
- package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
- package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
- package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
- package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
- package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
- package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
- package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
- package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
- package/templates/api/src/_platform/_shared/service/access.ts +2 -2
- package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
- package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
- package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
- package/templates/api/src/_platform/manifest.yaml +1 -0
- package/templates/api/src/_platform/market/api.ts +883 -0
- package/templates/api/src/_platform/market/bootstrap.ts +39 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
- package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
- package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
- package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
- package/templates/api/src/_platform/schema.yaml +21 -4
- package/templates/api/src/_platform/seed.sql +2 -2
- package/templates/api/src/_platform/solutions/api.ts +212 -0
- package/templates/api/src/demo/manifest.yaml +5 -0
- package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
- package/templates/api/src/demo/schema.yaml +11 -0
- package/templates/api/src/demo/seed.sql +5 -0
- package/templates/api/src/demo/todos/api.ts +59 -0
- package/templates/api/src/home/line/api.ts +57 -0
- package/templates/api/src/home/manifest.yaml +6 -0
- package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
- package/templates/api/src/home/pie/api.ts +40 -0
- package/templates/api/src/home/schema.yaml +14 -0
- package/templates/api/src/home/seed.sql +70 -0
- package/templates/api/src/system/oplog/api.ts +44 -2
- package/templates/api/tests/fixture.ts +16 -0
- package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
- package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
- package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
- package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
- package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
- package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
- package/templates/api/tests/lifecycle/register.spec.ts +194 -0
- package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
- package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
- package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
- package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
- package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
- package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
- package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
- package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
- package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
- package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
- package/templates/api/tests/rbac-settings.spec.ts +8 -0
- package/templates/init.include.yaml +62 -0
- package/templates/openapi.json +527 -0
- package/templates/package.json +52 -0
- package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
- package/templates/web/src/_platform/__client/api.ts +335 -0
- package/templates/web/src/about/entry.ts +42 -0
- package/templates/web/src/about/locales/en-US.json +16 -0
- package/templates/web/src/about/locales/zh-CN.json +16 -0
- package/templates/web/src/about/pages/constants.ts +20 -0
- package/templates/web/src/about/pages/index.tsx +135 -0
- package/templates/web/src/access/entry.ts +74 -0
- package/templates/web/src/access/locales/en-US.json +46 -0
- package/templates/web/src/access/locales/zh-CN.json +46 -0
- package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
- package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/button-control/index.tsx +181 -0
- package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/page-control/index.tsx +142 -0
- package/templates/web/src/demo/__client/api.schemas.ts +20 -0
- package/templates/web/src/demo/__client/api.ts +89 -0
- package/templates/web/src/exception/entry.ts +80 -0
- package/templates/web/src/exception/locales/en-US.json +13 -0
- package/templates/web/src/exception/locales/zh-CN.json +13 -0
- package/templates/web/src/exception/pages/403/index.tsx +30 -0
- package/templates/web/src/exception/pages/404/index.tsx +30 -0
- package/templates/web/src/exception/pages/500/index.tsx +30 -0
- package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
- package/templates/web/src/home/__client/api.schemas.ts +22 -0
- package/templates/web/src/home/__client/api.ts +113 -0
- package/templates/web/src/login/locales/en-US.json +6 -0
- package/templates/web/src/login/locales/zh-CN.json +6 -0
- package/templates/web/src/login/pages/login.tsx +67 -18
- package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
- package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
- package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
- package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
- package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
- package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
- package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
- package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
- package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
- package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
- package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
- package/templates/web/src/oj-market/api/client.ts +135 -0
- package/templates/web/src/oj-market/api/context.ts +11 -0
- package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
- package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
- package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
- package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
- package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
- package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
- package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
- package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
- package/templates/web/src/oj-market/entry.ts +27 -0
- package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
- package/templates/web/src/oj-market/locales/en-US.json +100 -0
- package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
- package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
- package/templates/web/src/oj-market/types.ts +84 -0
- package/templates/web/src/oj-market/utils/poll.ts +36 -0
- package/templates/web/src/oj-market/utils/semver.ts +14 -0
- package/templates/web/src/outside/entry.ts +80 -0
- package/templates/web/src/outside/locales/en-US.json +10 -0
- package/templates/web/src/outside/locales/zh-CN.json +10 -0
- package/templates/web/src/personal-center/entry.ts +1 -10
- package/templates/web/src/personal-center/locales/en-US.json +1 -5
- package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
- package/templates/web/src/route-nest/entry.ts +73 -0
- package/templates/web/src/route-nest/locales/en-US.json +9 -0
- package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
- package/templates/web/src/system/__client/api.schemas.ts +12 -0
- package/templates/web/src/system/__client/api.ts +25 -1
- package/templates/web/src/system/pages/oplog/index.tsx +1 -1
- package/templates/web/src/system/pages/roles/index.tsx +1 -0
- package/templates/web/src/system/pages/settings/index.tsx +1 -1
- package/templates/web/src/system/pages/users/index.tsx +1 -1
- package/templates/web.config.ts +32 -40
- package/vendor/host-versions.json +3 -3
- package/templates/pnpm-workspace.yaml +0 -14
- package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
// BDD:状态机编排 + 任务注册表(P1-10,D11/OCP/SRP)。
|
|
2
|
+
// 十阶段声明式状态机(STAGES 表)+ lockCommit 显式 stage(migrate 后,D11)+ 任务注册表
|
|
3
|
+
// (.install-tasks/<id>.json:保留 10 条、超时 running 判 failed、message 脱敏)。
|
|
4
|
+
// 失败语义三分(D11):锁前(preflight..lock_commit)失败 → 清场重跑(零改锁零改库表);
|
|
5
|
+
// 锁后(registering..smoke)失败 → status:"partial" + 回滚指引 key(lifecycle.stage.*,附录 A);
|
|
6
|
+
// 切换前 verifyTarget() 复验(staging 产物完整性;sha256/seq 对齐分别在 verifying/migrating 段已复核)。
|
|
7
|
+
// reconcile() 入口对账自愈:锁 ↔ 账本 head(D11)——锁指新版账本落后 → 回指旧版(.bak + 旧目录俱在)
|
|
8
|
+
// 或摘除记 failed;启动/对账时点超时 running 判 failed(R13/B9/R31)。
|
|
9
|
+
// oj 原生扁平锁(D12):`name: version` 字符串表,全量重写经 atomicWriteText(.bak 先备份,恢复用例),
|
|
10
|
+
// 写后读回校验重试收敛(S1-3:oj build load→save 窗口会吞并发外部写)。
|
|
11
|
+
// 任务互斥 .install-tasks/.lock(R17/R31):mkdir EEXIST 等价 O_CREAT|O_EXCL,owner.json 时间戳
|
|
12
|
+
// 过期(30min)可接管。setEnabled 四处同写(D12/AC-P1-6):锁摘除/恢复 + sidecar + route + modules。
|
|
13
|
+
// 步骤面全 stub 注入(DIP):fetch/exec/tar/reload 不真跑;register/smoke/audit 走真实内存库夹具。
|
|
14
|
+
/* eslint-disable test/prefer-lowercase-title -- 任务/AC 编号是验收锚点,保留大写(register.spec 同约定) */
|
|
15
|
+
import type { FsFace } from "../../src/_platform/_lifecycle/atomic";
|
|
16
|
+
import type { InstallRequest } from "../../src/_platform/_lifecycle/installer";
|
|
17
|
+
import type { StateStore } from "../../src/_platform/_lifecycle/state";
|
|
18
|
+
import type { ExecFace } from "../../src/_platform/_lifecycle/steps/extract";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import * as fsp from "node:fs/promises";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
24
|
+
import { defaultTestKey, nodeVerifyEd25519, signedArchiveFor } from "../../../../src/lifecycle/__tests__/helpers";
|
|
25
|
+
import { atomicWriteText } from "../../src/_platform/_lifecycle/atomic";
|
|
26
|
+
import { gcVersions, reconcile, runInstall, setEnabled, STAGES } from "../../src/_platform/_lifecycle/installer";
|
|
27
|
+
import { acquireTaskLock, commitLockEntry, parseLock, readLock, serializeLock, writeLock } from "../../src/_platform/_lifecycle/lock";
|
|
28
|
+
import { createStateStore } from "../../src/_platform/_lifecycle/state";
|
|
29
|
+
import { getTask, readTasks, redact, sweepTimedOut, writeTask } from "../../src/_platform/_lifecycle/tasks";
|
|
30
|
+
import { createTrustStore } from "../../src/_platform/_lifecycle/trust";
|
|
31
|
+
import { installDb, MARKET_HISTORY_DDL } from "../fixture";
|
|
32
|
+
|
|
33
|
+
const g = globalThis as any;
|
|
34
|
+
|
|
35
|
+
/** 共享测试 key(P2-3:夹具签名与信任库播种同一把;无私钥入仓) */
|
|
36
|
+
const TEST_KEY = defaultTestKey();
|
|
37
|
+
const TRUST_KEY_ID = "test.publisher";
|
|
38
|
+
|
|
39
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
40
|
+
const out = new Uint8Array(hex.length / 2);
|
|
41
|
+
for (let i = 0; i < out.length; i++)
|
|
42
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ARCHIVE = new TextEncoder().encode("demo-archive-bytes\n");
|
|
47
|
+
const SHA256 = createHash("sha256").update(ARCHIVE).digest("hex");
|
|
48
|
+
const ENTRY = new TextEncoder().encode("console.log(\"demo entry\");\n");
|
|
49
|
+
const CSS = new TextEncoder().encode(".demo{color:red}\n");
|
|
50
|
+
const sha384b64 = (d: Uint8Array) => createHash("sha384").update(d).digest("base64");
|
|
51
|
+
const MIGRATE_LINE = JSON.stringify({ results: [{ module: "demo-solution", ok: true, applied: [{ version: 3, name: "create_todos" }] }] });
|
|
52
|
+
|
|
53
|
+
/** 十阶段表(契约附录 A stage 名逐字:lock_commit 为 D11①扩展组,audit 恒末位) */
|
|
54
|
+
const STAGE_ORDER = ["preflight", "downloading", "verifying", "extracting", "migrating", "lock_commit", "registering", "deploying_web", "reloading", "smoke", "audit"];
|
|
55
|
+
|
|
56
|
+
let root: string;
|
|
57
|
+
let sharedRoot: string;
|
|
58
|
+
let staging: string;
|
|
59
|
+
let taskDir: string;
|
|
60
|
+
let siteModulesDir: string;
|
|
61
|
+
let modulesJsonPath: string;
|
|
62
|
+
let dbPath: string;
|
|
63
|
+
let lockPath: string;
|
|
64
|
+
let state: StateStore;
|
|
65
|
+
let trustPath: string;
|
|
66
|
+
let dbo: any;
|
|
67
|
+
|
|
68
|
+
function nodeFsFace(): FsFace {
|
|
69
|
+
return {
|
|
70
|
+
writeFile: (path, data) => fsp.writeFile(path, data),
|
|
71
|
+
readFile: path => fsp.readFile(path),
|
|
72
|
+
mkdir: (path, opts) => fsp.mkdir(path, opts),
|
|
73
|
+
remove: (path, opts) => fsp.rm(path, opts),
|
|
74
|
+
rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
|
|
75
|
+
copyFile: (src, dest) => fsp.copyFile(src, dest),
|
|
76
|
+
readdir: path => fsp.readdir(path),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function manifestFixture(version = "0.1.0"): any {
|
|
81
|
+
return {
|
|
82
|
+
name: "demo-solution",
|
|
83
|
+
version,
|
|
84
|
+
summary: "演示方案",
|
|
85
|
+
peerRuntime: ">=0.1.0",
|
|
86
|
+
archive: { file: `demo-solution-${version}.tgz`, sha256: SHA256, size: ARCHIVE.byteLength, signature: signedArchiveFor(ARCHIVE, "demo-solution", version, { keyPair: TEST_KEY }).sig },
|
|
87
|
+
api: { prefix: "/api/demo-solution", anonymous: ["demo-solution/ping"] },
|
|
88
|
+
web: { entry: `modules/demo-solution/${version}/entry.js`, css: [`modules/demo-solution/${version}/main.css`] },
|
|
89
|
+
routes: [
|
|
90
|
+
{ path: "/demo-solution", category: "main", order: 200, title_key: "demo-solution:menu.main" },
|
|
91
|
+
{ path: "/demo-solution/ping", category: "hidden", order: 205, title_key: "demo-solution:menu.ping" },
|
|
92
|
+
],
|
|
93
|
+
permissions: [{ code: "demo-solution.view", title_key: "demo-solution:perm.view" }],
|
|
94
|
+
roles: [{ name: "demo-solution.admin", title_key: "demo-solution:role.admin", permissions: ["demo-solution.view"] }],
|
|
95
|
+
settings: {
|
|
96
|
+
groups: [{
|
|
97
|
+
group: "display",
|
|
98
|
+
label_key: "demo-solution:settings.group.display",
|
|
99
|
+
items: [{ key: "demo-solution.display.theme", type: "enum", options: ["dark", "light"] }],
|
|
100
|
+
}],
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** bundle exec stub:tar list 返回 verbose listing、tar extract 真写出 §2.9 产物树(C2 manifest + api 模块目录 + web 树)、oj migrate 返回 S1-6 合同 JSON */
|
|
106
|
+
function bundleExec(): ExecFace & { calls: string[][] } {
|
|
107
|
+
const calls: string[][] = [];
|
|
108
|
+
return {
|
|
109
|
+
calls,
|
|
110
|
+
run: async (argv) => {
|
|
111
|
+
calls.push([...argv]);
|
|
112
|
+
if (argv[0] === "tar" && argv[1] === "-tvzf") {
|
|
113
|
+
const v = /demo-solution-([\w.-]+)\.tgz$/.exec(argv[2] ?? "")?.[1] ?? "0.1.0";
|
|
114
|
+
return { code: 0, stdout: `manifest.yaml\napi/demo-solution-${v}/manifest.yaml\napi/demo-solution-${v}/routes.js\napi/demo-solution-${v}/migrations/0001__init.sql\nweb/modules/demo-solution/${v}/entry.js\nweb/modules/demo-solution/${v}/main.css\n`, stderr: "" };
|
|
115
|
+
}
|
|
116
|
+
if (argv[0] === "tar" && argv[1] === "-xzf") {
|
|
117
|
+
const v = /demo-solution-([\w.-]+)\.tgz$/.exec(argv[2] ?? "")?.[1] ?? "0.1.0";
|
|
118
|
+
const dest = argv[4]!;
|
|
119
|
+
const apiDir = join(dest, "api", `demo-solution-${v}`, "migrations");
|
|
120
|
+
const dir = join(dest, "web", "modules", "demo-solution", v);
|
|
121
|
+
await fsp.mkdir(apiDir, { recursive: true });
|
|
122
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
123
|
+
// C2 Form A(staging 根)
|
|
124
|
+
await fsp.writeFile(join(dest, "manifest.yaml"), `name: demo-solution\nversion: ${v}\n`);
|
|
125
|
+
// oj 原生清单(api 模块目录内,desc === summary,C2-31 消费面;JSON 文本 = g.yaml.parse stub 可解析形态)
|
|
126
|
+
await fsp.writeFile(join(dest, "api", `demo-solution-${v}`, "manifest.yaml"), JSON.stringify({ name: "demo-solution", desc: "演示方案", version: v }));
|
|
127
|
+
await fsp.writeFile(join(dest, "api", `demo-solution-${v}`, "routes.js"), "export {};\n");
|
|
128
|
+
await fsp.writeFile(join(apiDir, "0001__init.sql"), "CREATE TABLE note (id INTEGER PRIMARY KEY);\n");
|
|
129
|
+
await fsp.writeFile(join(dir, "entry.js"), ENTRY);
|
|
130
|
+
await fsp.writeFile(join(dir, "main.css"), CSS);
|
|
131
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
132
|
+
}
|
|
133
|
+
if (argv[0] === "oj" && argv[1] === "migrate") {
|
|
134
|
+
return { code: 0, stdout: `${MIGRATE_LINE}\n`, stderr: "" };
|
|
135
|
+
}
|
|
136
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function makeReq(overrides: Partial<InstallRequest> = {}): InstallRequest {
|
|
142
|
+
const version = overrides.version ?? "0.1.0";
|
|
143
|
+
return {
|
|
144
|
+
manifest: manifestFixture(version),
|
|
145
|
+
name: "demo-solution",
|
|
146
|
+
version,
|
|
147
|
+
state,
|
|
148
|
+
archiveUrl: `https://cdn.example.com/demo-solution-${version}.tgz?token=should-not-leak`,
|
|
149
|
+
sharedRoot,
|
|
150
|
+
staging,
|
|
151
|
+
taskDir,
|
|
152
|
+
dbPath,
|
|
153
|
+
siteModulesDir,
|
|
154
|
+
modulesJsonPath,
|
|
155
|
+
apiPrefix: "/api/demo-solution",
|
|
156
|
+
configPath: join(root, "config.yaml"),
|
|
157
|
+
fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer }),
|
|
158
|
+
exec: bundleExec(),
|
|
159
|
+
reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] }) },
|
|
160
|
+
sha256Hex: d => createHash("sha256").update(d).digest("hex"),
|
|
161
|
+
verifyEd25519: nodeVerifyEd25519,
|
|
162
|
+
trustPath,
|
|
163
|
+
sha384B64: sha384b64,
|
|
164
|
+
hostVersions: { "@oj-module/runtime": "0.1.77" },
|
|
165
|
+
ledgerHead: null,
|
|
166
|
+
...overrides,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function rows(sql: string): Record<string, unknown>[] {
|
|
171
|
+
return dbo.prepare(sql).all() as Record<string, unknown>[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
beforeEach(async () => {
|
|
175
|
+
root = await fsp.mkdtemp(join(tmpdir(), "ojm-installer-"));
|
|
176
|
+
sharedRoot = join(root, "modules");
|
|
177
|
+
staging = join(sharedRoot, ".staging");
|
|
178
|
+
taskDir = join(sharedRoot, ".install-tasks");
|
|
179
|
+
lockPath = join(sharedRoot, "manifests.yaml");
|
|
180
|
+
siteModulesDir = join(root, "dist", "modules");
|
|
181
|
+
modulesJsonPath = join(root, "dist", "modules.json");
|
|
182
|
+
dbPath = join(root, "db.sqlite");
|
|
183
|
+
await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
|
|
184
|
+
// 站点 config.yaml 夹具(reloading 段 vars.ojm_install_seq 自增消费面;模板 config.yaml 随发形态)
|
|
185
|
+
await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n ojm_install_seq: 0\nauth:\n anonymous_paths:\n - /auth/login\n");
|
|
186
|
+
g.fs = nodeFsFace();
|
|
187
|
+
g.yaml = { parse: async (t: string) => JSON.parse(t) };
|
|
188
|
+
g.http = {};
|
|
189
|
+
dbo = installDb();
|
|
190
|
+
state = await createStateStore(join(sharedRoot, ".solutions", "state.yaml"));
|
|
191
|
+
trustPath = join(sharedRoot, ".solutions", "trusted-keys.yaml");
|
|
192
|
+
// 播种信任库(走真实 store;key_id 与夹具签名同一把 TEST_KEY)
|
|
193
|
+
await createTrustStore(trustPath, { audit: () => {}, fingerprintOf: pk => createHash("sha256").update(hexToBytes(pk)).digest("hex") }).add(TRUST_KEY_ID, TEST_KEY.publicKeyHex);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
afterEach(async () => {
|
|
197
|
+
g.fs = undefined;
|
|
198
|
+
g.http = undefined;
|
|
199
|
+
g.db = undefined;
|
|
200
|
+
g.yaml = undefined;
|
|
201
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("STAGES 声明式状态机表", () => {
|
|
205
|
+
it("十阶段表逐序:preflight..migrating → lock_commit(D11 migrate 后)→ registering..smoke → audit 恒末位", () => {
|
|
206
|
+
expect(STAGES.map(s => s.name)).toEqual(STAGE_ORDER);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实内存库)", () => {
|
|
211
|
+
it("stage 顺序推进至 done;锁/版本目录/web 落位/sidecar/注册四表/终态审计/任务文件全就位", async () => {
|
|
212
|
+
const t = await runInstall(makeReq());
|
|
213
|
+
expect(t.status).toBe("done");
|
|
214
|
+
expect(t.stage).toBe("done");
|
|
215
|
+
expect(t.progress).toBe(100);
|
|
216
|
+
expect(t.error).toBeUndefined();
|
|
217
|
+
expect(t.stages.map(s => s.stage)).toEqual(STAGE_ORDER);
|
|
218
|
+
expect(t.stages.every(s => s.ok)).toBe(true);
|
|
219
|
+
|
|
220
|
+
// oj 原生扁平锁(D12):name: version 字符串表,无嵌套无额外键
|
|
221
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual({ "demo-solution": "0.1.0" });
|
|
222
|
+
// 版本目录就位(锁指向的目录先于锁落盘,S1-4 同序)。B-11:发布面 = 包内 api 模块目录
|
|
223
|
+
// (oj 原生清单 + routes.js + migrations/,serve 装配消费面),web 树不进版本目录
|
|
224
|
+
const versionDir = join(sharedRoot, "demo-solution-0.1.0");
|
|
225
|
+
await fsp.access(join(versionDir, "manifest.yaml"));
|
|
226
|
+
await fsp.access(join(versionDir, "routes.js"));
|
|
227
|
+
await fsp.access(join(versionDir, "migrations", "0001__init.sql"));
|
|
228
|
+
await expect(fsp.access(join(versionDir, "web"))).rejects.toThrow();
|
|
229
|
+
// reloading 段触发变更已落盘:vars.ojm_install_seq 自增(B-11,PRD E5 口径)
|
|
230
|
+
const cfg = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
|
|
231
|
+
expect(cfg).toContain("ojm_install_seq: 1");
|
|
232
|
+
// C2-26 anonymous 运行时接线(P2-15③):方案匿名端点补进 auth.anonymous_paths(幂等追加)
|
|
233
|
+
expect(cfg).toContain("- /demo-solution/ping");
|
|
234
|
+
// web 产物落位 + modules.json 条目(R10 integrity 为包内容重算值)
|
|
235
|
+
await fsp.access(join(siteModulesDir, "demo-solution", "0.1.0", "entry.js"));
|
|
236
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8"));
|
|
237
|
+
expect(list[0]).toMatchObject({
|
|
238
|
+
name: "demo-solution",
|
|
239
|
+
entry: "modules/demo-solution/0.1.0/entry.js",
|
|
240
|
+
enabled: true,
|
|
241
|
+
integrity: `sha384-${sha384b64(ENTRY)}`,
|
|
242
|
+
peerRuntime: ">=0.1.0",
|
|
243
|
+
});
|
|
244
|
+
// sidecar(D12 元数据):版本 + enabled + installedAt
|
|
245
|
+
expect(state.read().solutions["demo-solution"]).toMatchObject({ version: "0.1.0", enabled: true });
|
|
246
|
+
// 注册四表(D10 出口)+ 冒烟消费同一真源
|
|
247
|
+
expect(rows("SELECT path FROM sys_solution_route WHERE module = 'demo-solution'")).toHaveLength(2);
|
|
248
|
+
expect(rows("SELECT name FROM sys_role WHERE module = 'demo-solution'")).toHaveLength(1);
|
|
249
|
+
// 迁移 applied max seq 入任务记录(对账 floor 依据)
|
|
250
|
+
expect(t.appliedSeq).toBe(3);
|
|
251
|
+
// 终态审计(AC-P1-14):solution.install 落 oplog,detail 带 status/taskId
|
|
252
|
+
const oplog = rows("SELECT action, detail FROM sys_oplog WHERE action = 'solution.install'");
|
|
253
|
+
expect(oplog).toHaveLength(1);
|
|
254
|
+
expect(String(oplog[0]!.detail)).toContain("\"status\":\"done\"");
|
|
255
|
+
expect(String(oplog[0]!.detail)).toContain(t.id);
|
|
256
|
+
// 任务文件落盘可查(800ms 轮询消费面)
|
|
257
|
+
const saved = await getTask(taskDir, t.id);
|
|
258
|
+
expect(saved?.status).toBe("done");
|
|
259
|
+
expect(saved?.appliedSeq).toBe(3);
|
|
260
|
+
expect(await getTask(taskDir, "nope")).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("P2-13 spawn 流式双路径:appliedSeq 仍在(跨 chunk 拼接解析)+ 进度字段形状不变(轮询消费面兼容)", async () => {
|
|
264
|
+
const base = bundleExec();
|
|
265
|
+
const exec: ExecFace = {
|
|
266
|
+
run: base.run,
|
|
267
|
+
spawn: async (argv) => {
|
|
268
|
+
if (argv[0] === "oj" && argv[1] === "migrate") {
|
|
269
|
+
// S1-6 JSON 恰一行切成 3 块(migrate 段回调内自累积跨块拼接——installer 侧经 onMigrateOutput 捕获)
|
|
270
|
+
const bytes = new TextEncoder().encode(`${MIGRATE_LINE}\n`);
|
|
271
|
+
const chunks = [bytes.slice(0, 7), bytes.slice(7, 19), bytes.slice(19)];
|
|
272
|
+
async function* gen() {
|
|
273
|
+
for (const c of chunks)
|
|
274
|
+
yield c;
|
|
275
|
+
}
|
|
276
|
+
return { stdout: gen(), stderr: gen(), exit: async () => ({ code: 0 }), kill: () => {} };
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`unexpected spawn: ${argv.join(" ")}`);
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const t = await runInstall(makeReq({ exec }));
|
|
282
|
+
expect(t.status).toBe("done");
|
|
283
|
+
expect(t.appliedSeq).toBe(3);
|
|
284
|
+
expect(typeof t.progress).toBe("number");
|
|
285
|
+
expect(t.progress).toBe(100);
|
|
286
|
+
const saved = await getTask(taskDir, t.id);
|
|
287
|
+
expect(saved?.appliedSeq).toBe(3);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("同版本重装幂等、不同版本目录共存(AC-P1-3):两版本目录并存,锁指新版", async () => {
|
|
291
|
+
const first = await runInstall(makeReq({ version: "0.1.0" }));
|
|
292
|
+
expect(first.status).toBe("done");
|
|
293
|
+
const again = await runInstall(makeReq({ version: "0.1.0" }));
|
|
294
|
+
expect(again.status).toBe("done");
|
|
295
|
+
const up = await runInstall(makeReq({ version: "0.2.0" }));
|
|
296
|
+
expect(up.status).toBe("done");
|
|
297
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual({ "demo-solution": "0.2.0" });
|
|
298
|
+
await fsp.access(join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml")); // 旧版本目录保留(回滚可用)
|
|
299
|
+
await fsp.access(join(sharedRoot, "demo-solution-0.2.0", "manifest.yaml"));
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("P3-2 版本 GC(KEEP_VERSIONS=3:state history 时间序 + 锁版本保护)", () => {
|
|
304
|
+
it("装 5 个不同版本 → 仅留最近 3 版目录、旧版本目录被清理、sidecar history 截取最近 3", async () => {
|
|
305
|
+
for (const v of ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0"]) {
|
|
306
|
+
const t = await runInstall(makeReq({ version: v }));
|
|
307
|
+
expect(t.status).toBe("done");
|
|
308
|
+
}
|
|
309
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual({ "demo-solution": "0.5.0" });
|
|
310
|
+
// 仅最近 3 版目录在盘,最旧两版被 GC 清理
|
|
311
|
+
for (const v of ["0.1.0", "0.2.0"]) {
|
|
312
|
+
await expect(fsp.access(join(sharedRoot, `demo-solution-${v}`))).rejects.toThrow();
|
|
313
|
+
}
|
|
314
|
+
for (const v of ["0.3.0", "0.4.0", "0.5.0"]) {
|
|
315
|
+
await fsp.access(join(sharedRoot, `demo-solution-${v}`, "manifest.yaml"));
|
|
316
|
+
}
|
|
317
|
+
// sidecar history 截取为最近 3(倒序:最新在前)
|
|
318
|
+
expect(state.read().solutions["demo-solution"]?.history?.map(h => h.version)).toEqual(["0.5.0", "0.4.0", "0.3.0"]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("gcVersions 锁版本保护:history 超出 N 但锁指向最旧版本时,最旧版本目录与 history 保留(回滚目标不可被 GC 误删)", async () => {
|
|
322
|
+
// 预置 sidecar history 5 个版本(倒序),最旧为 0.1.0
|
|
323
|
+
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000, history: [
|
|
324
|
+
{ version: "0.5.0", installedAt: 5000 },
|
|
325
|
+
{ version: "0.4.0", installedAt: 4000 },
|
|
326
|
+
{ version: "0.3.0", installedAt: 3000 },
|
|
327
|
+
{ version: "0.2.0", installedAt: 2000 },
|
|
328
|
+
{ version: "0.1.0", installedAt: 1000 },
|
|
329
|
+
] } } });
|
|
330
|
+
// 预建 5 个版本目录
|
|
331
|
+
for (const v of ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0"]) {
|
|
332
|
+
await fsp.mkdir(join(sharedRoot, `demo-solution-${v}`), { recursive: true });
|
|
333
|
+
}
|
|
334
|
+
// 锁指向最旧版本 0.1.0(reconcile 回滚到旧版的落地形态)
|
|
335
|
+
await writeLock(lockPath, { "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
336
|
+
await gcVersions(sharedRoot, "demo-solution", state);
|
|
337
|
+
// 锁版本 0.1.0 保留;0.2.0 超出「最近 3 ∪ 锁版本」被清理
|
|
338
|
+
await fsp.access(join(sharedRoot, "demo-solution-0.1.0"));
|
|
339
|
+
await fsp.access(join(sharedRoot, "demo-solution-0.3.0"));
|
|
340
|
+
await fsp.access(join(sharedRoot, "demo-solution-0.4.0"));
|
|
341
|
+
await fsp.access(join(sharedRoot, "demo-solution-0.5.0"));
|
|
342
|
+
await expect(fsp.access(join(sharedRoot, "demo-solution-0.2.0"))).rejects.toThrow();
|
|
343
|
+
// history 保留锁版本(最旧 0.1.0 在,0.2.0 出)
|
|
344
|
+
expect(state.read().solutions["demo-solution"]?.history?.map(h => h.version)).toEqual(["0.5.0", "0.4.0", "0.3.0", "0.1.0"]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("同版本重装:history 不重复、installedAt 刷新,不触发 GC 涨栈", async () => {
|
|
348
|
+
await runInstall(makeReq({ version: "0.1.0" }));
|
|
349
|
+
await runInstall(makeReq({ version: "0.1.0" }));
|
|
350
|
+
await runInstall(makeReq({ version: "0.2.0" }));
|
|
351
|
+
const hist = state.read().solutions["demo-solution"]?.history?.map(h => h.version);
|
|
352
|
+
expect(hist).toEqual(["0.2.0", "0.1.0"]); // 0.1.0 仅一条(重装去重,installedAt 刷新到更晚)
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("getTask/taskOf id 白名单(Finding B:穿越 id 不得读出 taskDir 之外)", () => {
|
|
357
|
+
it("id=../../dist/modules 形态 → null(即使穿越目标存在合法 JSON 也不可读);合法 id 不受影响", async () => {
|
|
358
|
+
// 穿越目标预置可读 JSON:白名单缺失时 getTask 会把它当任务记录读出(红)。
|
|
359
|
+
// taskDir 须真实存在——内核路径游走要穿过它,缺失时 ../ 段直接 ENOENT(测不出穿越)
|
|
360
|
+
await fsp.mkdir(taskDir, { recursive: true });
|
|
361
|
+
await fsp.mkdir(join(root, "dist"), { recursive: true });
|
|
362
|
+
await fsp.writeFile(join(root, "dist", "modules.json"), JSON.stringify({ id: "stolen", status: "done", stages: [] }));
|
|
363
|
+
expect(await getTask(taskDir, "../../dist/modules")).toBeNull();
|
|
364
|
+
expect(await getTask(taskDir, "../sibling")).toBeNull();
|
|
365
|
+
// 合法 id 读写不受白名单影响(lock/state 键白名单同款字符集)
|
|
366
|
+
await writeTask(taskDir, { id: "t-ok", name: "demo-solution", version: "0.1.0", status: "done", stage: "done", progress: 100, message: "", stages: [], startedAt: 1 });
|
|
367
|
+
expect((await getTask(taskDir, "t-ok"))?.status).toBe("done");
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("失败语义①锁前清场(D11:零改锁零改库表,可重跑)", () => {
|
|
372
|
+
it("extract 失败 → status failed + extracting 指引 key;staging 清场、锁/清单/sidecar/库表零改动", async () => {
|
|
373
|
+
const exec: ExecFace = {
|
|
374
|
+
run: async argv => (argv[1] === "-xzf" ? { code: 1, stdout: "", stderr: "tar: exiting with failure status" } : { code: 0, stdout: "manifest.yaml\n", stderr: "" }),
|
|
375
|
+
};
|
|
376
|
+
const t = await runInstall(makeReq({ exec }));
|
|
377
|
+
expect(t.status).toBe("failed");
|
|
378
|
+
expect(t.stage).toBe("extracting");
|
|
379
|
+
expect(t.error?.hintKey).toBe("lifecycle.stage.extracting.hint");
|
|
380
|
+
expect(t.error?.retryable).toBe(true);
|
|
381
|
+
expect(t.stages.map(s => s.stage)).toEqual(["preflight", "downloading", "verifying", "extracting"]);
|
|
382
|
+
// 清场:staging 删除(下载产物随 staging 一并清)
|
|
383
|
+
await expect(fsp.access(staging)).rejects.toThrow();
|
|
384
|
+
// 零改锁零落位
|
|
385
|
+
await expect(fsp.access(lockPath)).rejects.toThrow();
|
|
386
|
+
await expect(fsp.access(join(sharedRoot, "demo-solution-0.1.0"))).rejects.toThrow();
|
|
387
|
+
await expect(fsp.access(modulesJsonPath)).rejects.toThrow();
|
|
388
|
+
// 零改库表(注册未发生)零审计前置写
|
|
389
|
+
expect(rows("SELECT path FROM sys_solution_route")).toHaveLength(0);
|
|
390
|
+
expect(rows("SELECT * FROM sys_oplog")).toHaveLength(0);
|
|
391
|
+
// sidecar 未写(failed 非 done/partial)
|
|
392
|
+
expect(state.read().solutions).toEqual({});
|
|
393
|
+
// 任务文件已落(可观测)
|
|
394
|
+
expect((await getTask(taskDir, t.id))?.status).toBe("failed");
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("p3-11 宿主层 rollbackGuard 预锁拦截(OJ-9):包内迁移 max seq < ledgerHead → migrating 失败,零 exec 零备份", () => {
|
|
399
|
+
it("ledgerHead=5、包内仅 0001 → migrating failed(retryable=false),oj migrate 未执行、无 .bak 备份、锁前清场", async () => {
|
|
400
|
+
const exec = bundleExec();
|
|
401
|
+
const t = await runInstall(makeReq({ ledgerHead: 5, exec }));
|
|
402
|
+
expect(t.status).toBe("failed");
|
|
403
|
+
expect(t.stage).toBe("migrating");
|
|
404
|
+
expect(t.error?.retryable).toBe(false);
|
|
405
|
+
expect(String(t.error?.reason)).toContain("禁降版");
|
|
406
|
+
// 零 exec:oj migrate 未被调用(tar list/extract 属包准备,允许发生)
|
|
407
|
+
expect(exec.calls.filter(c => c[0] === "oj")).toHaveLength(0);
|
|
408
|
+
// 零备份:R14 前置备份未发生(宿主层在备份前拦截)
|
|
409
|
+
expect((await fsp.readdir(root)).filter(e => e.includes(".bak-"))).toHaveLength(0);
|
|
410
|
+
// 锁前清场(D11①):staging 清空、零落锁零落位
|
|
411
|
+
await expect(fsp.access(staging)).rejects.toThrow();
|
|
412
|
+
await expect(fsp.access(lockPath)).rejects.toThrow();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("近似真源缺失(包内无 migrations 目录)→ 宿主层放行,migrate 段 applied[] 事后复核兜底拦截", async () => {
|
|
416
|
+
const exec: ExecFace = {
|
|
417
|
+
run: async (argv) => {
|
|
418
|
+
if (argv[0] === "tar" && argv[1] === "-tvzf")
|
|
419
|
+
return { code: 0, stdout: "manifest.yaml\napi/demo-solution-0.1.0/manifest.yaml\napi/demo-solution-0.1.0/routes.js\n", stderr: "" };
|
|
420
|
+
if (argv[0] === "tar" && argv[1] === "-xzf") {
|
|
421
|
+
const apiDir = join(argv[4]!, "api", "demo-solution-0.1.0");
|
|
422
|
+
await fsp.mkdir(apiDir, { recursive: true });
|
|
423
|
+
await fsp.writeFile(join(argv[4]!, "manifest.yaml"), "name: demo-solution\nversion: 0.1.0\n");
|
|
424
|
+
await fsp.writeFile(join(apiDir, "manifest.yaml"), JSON.stringify({ name: "demo-solution", desc: "演示方案", version: "0.1.0" }));
|
|
425
|
+
await fsp.writeFile(join(apiDir, "routes.js"), "export {};\n");
|
|
426
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
427
|
+
}
|
|
428
|
+
if (argv[0] === "oj" && argv[1] === "migrate")
|
|
429
|
+
return { code: 0, stdout: `${MIGRATE_LINE}\n`, stderr: "" };
|
|
430
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
const t = await runInstall(makeReq({ ledgerHead: 5, exec }));
|
|
434
|
+
// 宿主层未拦(无文件名真源)→ oj migrate 真的跑了 → applied seq 3 < head 5 由 migrate 段复核拦下
|
|
435
|
+
expect(t.status).toBe("failed");
|
|
436
|
+
expect(t.stage).toBe("migrating");
|
|
437
|
+
expect(String(t.error?.reason)).toContain("账本 head 5");
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("U5 取消安装(P3-8):cancel token → h.kill + staging 清场 + 锁/目录回退装前 + 终态 cancelled", () => {
|
|
442
|
+
/**
|
|
443
|
+
* 取消用 harness:tar list/extract 走真实 bundleExec;oj migrate 走 spawn(逐块吐、spawn 启动后
|
|
444
|
+
* cancel 才为真、kill 记录调用)。cancel 仅在 migrate 段 spawn 实际启动且收过 ≥1 块后才为真——
|
|
445
|
+
* 避免误触 runInstall 段间 cancel 检测(真实 cancel 是布尔闭包,spawnStarted 门控贴近语义)
|
|
446
|
+
*/
|
|
447
|
+
function cancelHarness() {
|
|
448
|
+
let killed = false;
|
|
449
|
+
let spawnStarted = false;
|
|
450
|
+
let chunksInSpawn = 0;
|
|
451
|
+
const base = bundleExec();
|
|
452
|
+
const exec: ExecFace = {
|
|
453
|
+
run: (argv, opts) => base.run(argv, opts),
|
|
454
|
+
spawn: async (argv) => {
|
|
455
|
+
if (argv[0] === "oj" && argv[1] === "migrate") {
|
|
456
|
+
spawnStarted = true;
|
|
457
|
+
const bytes = new TextEncoder().encode(`${MIGRATE_LINE}\n`);
|
|
458
|
+
const chunks = [bytes.slice(0, 5), bytes.slice(5)];
|
|
459
|
+
async function* gen() {
|
|
460
|
+
for (const c of chunks) {
|
|
461
|
+
chunksInSpawn++;
|
|
462
|
+
yield c;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
stdout: gen(),
|
|
467
|
+
stderr: gen(),
|
|
468
|
+
exit: async () => ({ code: 137 }),
|
|
469
|
+
kill: () => {
|
|
470
|
+
killed = true;
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
throw new Error(`unexpected spawn: ${argv.join(" ")}`);
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
const cancel = () => spawnStarted && chunksInSpawn >= 1;
|
|
478
|
+
return { exec, cancel, killCalled: () => killed };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
it("migrate 段取消 → kill 调用、staging 清空、锁/版本目录回到装前、终态 failed+cancelled、审计末位落 oplog", async () => {
|
|
482
|
+
const h = cancelHarness();
|
|
483
|
+
const t = await runInstall(makeReq({ exec: h.exec, cancel: h.cancel }));
|
|
484
|
+
expect(t.status).toBe("failed");
|
|
485
|
+
expect(t.cancelled).toBe(true);
|
|
486
|
+
expect(h.killCalled()).toBe(true);
|
|
487
|
+
// staging 清空(下载/解压/迁移产物随之一并清)
|
|
488
|
+
await expect(fsp.access(staging)).rejects.toThrow();
|
|
489
|
+
// 首装装前无锁 → 取消后无锁文件(空锁拒启 S1-1.2,不污染)
|
|
490
|
+
await expect(fsp.access(lockPath)).rejects.toThrow();
|
|
491
|
+
// 版本目录不得残留(migrate 段取消 = 锁前,lock_commit 未达 → 无版本目录)
|
|
492
|
+
await expect(fsp.access(join(sharedRoot, "demo-solution-0.1.0"))).rejects.toThrow();
|
|
493
|
+
// 审计末位:solution.install 落 oplog(取消也须可观测,AC-P1-14)
|
|
494
|
+
const oplog = rows("SELECT detail FROM sys_oplog WHERE action = 'solution.install'");
|
|
495
|
+
expect(oplog).toHaveLength(1);
|
|
496
|
+
// 任务文件终态可查,不滞留 running
|
|
497
|
+
expect((await getTask(taskDir, t.id))?.status).toBe("failed");
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("升级场景取消(装前已有同名方案+锁):锁回退到装前旧版本、旧版本目录保留、新版本目录清除", async () => {
|
|
501
|
+
// 先装 0.1.0 成功,建立装前态
|
|
502
|
+
const first = await runInstall(makeReq({ version: "0.1.0" }));
|
|
503
|
+
expect(first.status).toBe("done");
|
|
504
|
+
expect(await readLock(lockPath)).toEqual({ "demo-solution": "0.1.0" });
|
|
505
|
+
// 升级到 0.2.0 途中取消
|
|
506
|
+
const h = cancelHarness();
|
|
507
|
+
const up = await runInstall(makeReq({ version: "0.2.0", exec: h.exec, cancel: h.cancel }));
|
|
508
|
+
expect(up.status).toBe("failed");
|
|
509
|
+
expect(up.cancelled).toBe(true);
|
|
510
|
+
expect(h.killCalled()).toBe(true);
|
|
511
|
+
// 锁回退到装前旧版本(撤销 0.2.0 的写入)
|
|
512
|
+
expect(await readLock(lockPath)).toEqual({ "demo-solution": "0.1.0" });
|
|
513
|
+
// 旧版本目录保留(回滚可用)
|
|
514
|
+
await fsp.access(join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml"));
|
|
515
|
+
// 新版本目录清除(升级取消不残留半成品)
|
|
516
|
+
await expect(fsp.access(join(sharedRoot, "demo-solution-0.2.0"))).rejects.toThrow();
|
|
517
|
+
// staging 清空
|
|
518
|
+
await expect(fsp.access(staging)).rejects.toThrow();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("幂等/可重入:取消 cleanup 对已清场态重复执行无副作用(staging 已不存在仍安全)", async () => {
|
|
522
|
+
const h = cancelHarness();
|
|
523
|
+
const t = await runInstall(makeReq({ exec: h.exec, cancel: h.cancel }));
|
|
524
|
+
expect(t.status).toBe("failed");
|
|
525
|
+
expect(t.cancelled).toBe(true);
|
|
526
|
+
// staging 已清:重复访问确认非存在(cleanup 重复调用幂等)
|
|
527
|
+
await expect(fsp.access(staging)).rejects.toThrow();
|
|
528
|
+
// 终态任务可查(不滞留 running)
|
|
529
|
+
expect((await getTask(taskDir, t.id))?.status).toBe("failed");
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe("第五终态 needs_trust(P2-3,G3 TOFU:未知 key_id 待确认,staging 不清场)", () => {
|
|
534
|
+
it("未知 key 装 → needs_trust + trust 三元组 + unknown_key 指引;锁/库表/sidecar/审计零改动,staging 留存", async () => {
|
|
535
|
+
const { generateKeyPair } = await import("../../../../src/lifecycle/keygen");
|
|
536
|
+
const { signedArchiveFor: signFor } = await import("../../../../src/lifecycle/__tests__/helpers");
|
|
537
|
+
const stranger = signFor(ARCHIVE, "demo-solution", "0.1.0", { keyPair: generateKeyPair(), keyId: "stranger.pub" });
|
|
538
|
+
const m = manifestFixture("0.1.0");
|
|
539
|
+
m.archive = { ...m.archive, signature: stranger.sig };
|
|
540
|
+
const t = await runInstall(makeReq({ manifest: m }));
|
|
541
|
+
expect(t.status).toBe("needs_trust");
|
|
542
|
+
expect(t.stage).toBe("verifying");
|
|
543
|
+
expect(t.error?.hintKey).toBe("lifecycle.stage.verifying.unknown_key");
|
|
544
|
+
expect(t.error?.retryable).toBe(false);
|
|
545
|
+
expect(t.trust).toMatchObject({ key_id: "stranger.pub", public_key: stranger.publicKeyHex });
|
|
546
|
+
expect(t.trust?.fingerprint).toMatch(/^[0-9a-f]{64}$/);
|
|
547
|
+
// 停在哪算哪:stages 止于 verifying
|
|
548
|
+
expect(t.stages.map(s => s.stage)).toEqual(["preflight", "downloading", "verifying"]);
|
|
549
|
+
// staging 不清场(file 源须重传、CDN 源重试自动重下——包体与 .sig 仍在场)
|
|
550
|
+
await expect(fsp.access(join(staging, "demo-solution-0.1.0.tgz"))).resolves.toBeUndefined();
|
|
551
|
+
// 锁/版本目录/清单/sidecar/库表/审计零改动(失败语义①同口径)
|
|
552
|
+
await expect(fsp.access(lockPath)).rejects.toThrow();
|
|
553
|
+
await expect(fsp.access(join(sharedRoot, "demo-solution-0.1.0"))).rejects.toThrow();
|
|
554
|
+
await expect(fsp.access(modulesJsonPath)).rejects.toThrow();
|
|
555
|
+
expect(rows("SELECT path FROM sys_solution_route")).toHaveLength(0);
|
|
556
|
+
expect(rows("SELECT * FROM sys_oplog")).toHaveLength(0);
|
|
557
|
+
expect(state.read().solutions).toEqual({});
|
|
558
|
+
// 任务文件落盘可观测(800ms 轮询消费面看得见待确认态)
|
|
559
|
+
const saved = await getTask(taskDir, t.id);
|
|
560
|
+
expect(saved?.status).toBe("needs_trust");
|
|
561
|
+
expect(saved?.trust?.key_id).toBe("stranger.pub");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("TOFU 全链:确认入信任库(端点同路径 store.add)后原样重试 → done", async () => {
|
|
565
|
+
const { generateKeyPair } = await import("../../../../src/lifecycle/keygen");
|
|
566
|
+
const { signedArchiveFor: signFor } = await import("../../../../src/lifecycle/__tests__/helpers");
|
|
567
|
+
const stranger = signFor(ARCHIVE, "demo-solution", "0.1.0", { keyPair: generateKeyPair(), keyId: "stranger.pub" });
|
|
568
|
+
const m = manifestFixture("0.1.0");
|
|
569
|
+
m.archive = { ...m.archive, signature: stranger.sig };
|
|
570
|
+
const first = await runInstall(makeReq({ manifest: m }));
|
|
571
|
+
expect(first.status).toBe("needs_trust");
|
|
572
|
+
// 确认:POST /market/trust {task_id} 的服务端同路径(store.add;operator 反查留端点侧)
|
|
573
|
+
await createTrustStore(trustPath, { audit: () => {}, fingerprintOf: pk => createHash("sha256").update(hexToBytes(pk)).digest("hex") }).add("stranger.pub", stranger.publicKeyHex);
|
|
574
|
+
const second = await runInstall(makeReq({ manifest: m }));
|
|
575
|
+
expect(second.status).toBe("done");
|
|
576
|
+
expect(second.stages.map(s => s.stage)).toContain("verifying");
|
|
577
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual({ "demo-solution": "0.1.0" });
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe("失败语义②锁后 partial(D11:部分生效 + 回滚指引)", () => {
|
|
582
|
+
it("smoke 失败 → status partial + smoke 指引 key;锁/版本目录/sidecar 已生效,终态审计记 partial", async () => {
|
|
583
|
+
const t = await runInstall(makeReq({ query: async () => null }));
|
|
584
|
+
expect(t.status).toBe("partial");
|
|
585
|
+
expect(t.stage).toBe("smoke");
|
|
586
|
+
expect(t.error?.hintKey).toBe("lifecycle.stage.smoke.hint");
|
|
587
|
+
expect(t.error?.retryable).toBe(false);
|
|
588
|
+
// 锁与版本目录已生效(partial 语义:部分生效,不回滚已落位产物)
|
|
589
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual({ "demo-solution": "0.1.0" });
|
|
590
|
+
await fsp.access(join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml"));
|
|
591
|
+
expect(state.read().solutions["demo-solution"]).toMatchObject({ version: "0.1.0", enabled: true });
|
|
592
|
+
// 终态审计(含失败,AC-P1-14):status=partial + failedStage
|
|
593
|
+
const oplog = rows("SELECT detail FROM sys_oplog WHERE action = 'solution.install'");
|
|
594
|
+
expect(oplog).toHaveLength(1);
|
|
595
|
+
expect(String(oplog[0]!.detail)).toContain("\"status\":\"partial\"");
|
|
596
|
+
expect(String(oplog[0]!.detail)).toContain("smoke");
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("终态审计守卫(Finding A:审计失败仅日志、不阻断任务终态,附录 B-5)", () => {
|
|
601
|
+
it("audit 注入抛非审计异常 + console 通道不可用(release isolate BadResource 形态)→ runInstall 仍返回正常终态,任务不滞留 running", async () => {
|
|
602
|
+
// release isolate:console.* 自身抛 BadResource(契约 B-8)——catch 里的 console.warn 会把
|
|
603
|
+
// 「仅日志」语义毒死并击穿 runInstall 终态段,端点 500、任务滞留 running 直到 30min sweep。
|
|
604
|
+
// 以会抛的 console spy 模拟该形态(node 环境 console 不会抛,直接注入测不出此路径)
|
|
605
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {
|
|
606
|
+
throw new Error("BadResource: console 通道在 release isolate 不可用");
|
|
607
|
+
});
|
|
608
|
+
try {
|
|
609
|
+
const t = await runInstall(makeReq({
|
|
610
|
+
audit: () => {
|
|
611
|
+
throw new Error("audit 后端炸了(非审计异常形态)");
|
|
612
|
+
},
|
|
613
|
+
}));
|
|
614
|
+
expect(t.status).toBe("done");
|
|
615
|
+
expect(t.stage).toBe("done");
|
|
616
|
+
const auditEntry = t.stages.find(s => s.stage === "audit");
|
|
617
|
+
expect(auditEntry?.ok).toBe(true);
|
|
618
|
+
expect(auditEntry?.message).toContain("audit 后端炸了");
|
|
619
|
+
// 任务文件终态可查(不滞留 running)
|
|
620
|
+
expect((await getTask(taskDir, t.id))?.status).toBe("done");
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
warn.mockRestore();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("sidecar 写失败 → 终态 done 不变、降级路径静默(禁 console,同上 BadResource 毒终态段)", async () => {
|
|
628
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
629
|
+
try {
|
|
630
|
+
const t = await runInstall(makeReq({
|
|
631
|
+
state: {
|
|
632
|
+
read: () => ({ solutions: {} }),
|
|
633
|
+
write: async () => { throw new Error("sidecar 盘满"); },
|
|
634
|
+
},
|
|
635
|
+
}));
|
|
636
|
+
expect(t.status).toBe("done");
|
|
637
|
+
expect(warn).not.toHaveBeenCalled();
|
|
638
|
+
expect((await getTask(taskDir, t.id))?.status).toBe("done");
|
|
639
|
+
}
|
|
640
|
+
finally {
|
|
641
|
+
warn.mockRestore();
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
describe("reconcile 入口对账自愈(D11:锁 ↔ 账本 head)", () => {
|
|
647
|
+
it("锁指新版但账本 head 落后 + .bak 与旧版本目录俱在 → 回指旧版(repoint)", async () => {
|
|
648
|
+
await writeLock(lockPath, { "demo-solution": "0.2.0", "auth": "0.1.0" });
|
|
649
|
+
await atomicWriteText(`${lockPath}.bak`, serializeLock({ "demo-solution": "0.1.0", "auth": "0.1.0" }));
|
|
650
|
+
await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
|
|
651
|
+
await fsp.mkdir(join(sharedRoot, "demo-solution-0.2.0"), { recursive: true });
|
|
652
|
+
const report = await reconcile({
|
|
653
|
+
lockPath,
|
|
654
|
+
sharedRoot,
|
|
655
|
+
taskDir,
|
|
656
|
+
ledgerHeadOf: async () => 3,
|
|
657
|
+
headFloorOf: async (_n, v) => (v === "0.2.0" ? 5 : 3),
|
|
658
|
+
});
|
|
659
|
+
expect(report.healed).toEqual([{
|
|
660
|
+
name: "demo-solution",
|
|
661
|
+
action: "repoint",
|
|
662
|
+
from: "0.2.0",
|
|
663
|
+
to: "0.1.0",
|
|
664
|
+
reason: expect.stringContaining("账本"),
|
|
665
|
+
}]);
|
|
666
|
+
expect(await readLock(lockPath)).toEqual({ "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("无旧版可指(.bak 缺失)→ 摘除条目 + 记 failed 任务(其余条目原样保留)", async () => {
|
|
670
|
+
await writeLock(lockPath, { "demo-solution": "0.2.0", "auth": "0.1.0" });
|
|
671
|
+
const report = await reconcile({
|
|
672
|
+
lockPath,
|
|
673
|
+
sharedRoot,
|
|
674
|
+
taskDir,
|
|
675
|
+
ledgerHeadOf: async () => 3,
|
|
676
|
+
headFloorOf: async (_n, v) => (v === "0.2.0" ? 5 : 3),
|
|
677
|
+
});
|
|
678
|
+
expect(report.healed).toEqual([expect.objectContaining({ name: "demo-solution", action: "remove", to: null })]);
|
|
679
|
+
expect(await readLock(lockPath)).toEqual({ auth: "0.1.0" });
|
|
680
|
+
const failed = (await readTasks(taskDir)).filter(x => x.name === "demo-solution" && x.status === "failed");
|
|
681
|
+
expect(failed).toHaveLength(1);
|
|
682
|
+
expect(failed[0]!.message).toContain("摘除");
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("账本一致 → 零自愈零改写(锁字节不变);对账时点超时 running 判 failed(R13/B9/R31)", async () => {
|
|
686
|
+
await writeLock(lockPath, { "demo-solution": "0.2.0" });
|
|
687
|
+
const before = await fsp.readFile(lockPath, "utf8");
|
|
688
|
+
const now = Date.now();
|
|
689
|
+
await writeTask(taskDir, {
|
|
690
|
+
id: "stale-running",
|
|
691
|
+
name: "demo-solution",
|
|
692
|
+
version: "0.2.0",
|
|
693
|
+
status: "running",
|
|
694
|
+
stage: "migrating",
|
|
695
|
+
progress: 30,
|
|
696
|
+
message: "执行中",
|
|
697
|
+
stages: [],
|
|
698
|
+
startedAt: now - 31 * 60_000,
|
|
699
|
+
});
|
|
700
|
+
const report = await reconcile({
|
|
701
|
+
lockPath,
|
|
702
|
+
sharedRoot,
|
|
703
|
+
taskDir,
|
|
704
|
+
ledgerHeadOf: async () => 5,
|
|
705
|
+
headFloorOf: async () => 5,
|
|
706
|
+
now,
|
|
707
|
+
});
|
|
708
|
+
expect(report.healed).toEqual([]);
|
|
709
|
+
expect(await fsp.readFile(lockPath, "utf8")).toBe(before);
|
|
710
|
+
const swept = await getTask(taskDir, "stale-running");
|
|
711
|
+
expect(swept?.status).toBe("failed");
|
|
712
|
+
expect(swept?.error?.reason).toContain("超时");
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
describe("p3-9 reconcile live 信任核对(P2-6#1,G5):吊销 key ⇒ 标 untrusted 且仍启用", () => {
|
|
717
|
+
async function revokeKey(): Promise<void> {
|
|
718
|
+
// 吊销:只阻断新装/升级,不动已装方案(G5);与 setEnabled 启用核对同信任库
|
|
719
|
+
await createTrustStore(trustPath, { audit: () => {}, fingerprintOf: pk => createHash("sha256").update(hexToBytes(pk)).digest("hex") }).remove(TRUST_KEY_ID);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
it("reconcile 后吊销 key ⇒ 方案标 untrusted 且仍启用(存量可用性承诺 G5)", async () => {
|
|
723
|
+
await runInstall(makeReq()); // sidecar 落 archive.key_id=test.publisher + trust_state=trusted
|
|
724
|
+
await revokeKey();
|
|
725
|
+
const report = await reconcile({
|
|
726
|
+
lockPath,
|
|
727
|
+
sharedRoot,
|
|
728
|
+
taskDir,
|
|
729
|
+
ledgerHeadOf: async () => 3,
|
|
730
|
+
headFloorOf: async () => 3,
|
|
731
|
+
trustPath,
|
|
732
|
+
state,
|
|
733
|
+
});
|
|
734
|
+
expect(report.healed).toEqual([]); // 账务一致,无自愈
|
|
735
|
+
// 仍启用:锁条目 / sidecar enabled 不动(G5 存量可用性承诺)
|
|
736
|
+
expect(await readLock(lockPath)).toEqual({ "demo-solution": "0.1.0" });
|
|
737
|
+
expect(state.read().solutions["demo-solution"]?.enabled).toBe(true);
|
|
738
|
+
// live 核对标 untrusted(G5:放行 + 告警 + 审计 + 深检标 untrusted)
|
|
739
|
+
expect(state.read().solutions["demo-solution"]?.trust_state).toBe("untrusted");
|
|
740
|
+
// archive 真源保留(切换不丢复验依据)
|
|
741
|
+
expect(state.read().solutions["demo-solution"]?.archive?.sha256).toBe(SHA256);
|
|
742
|
+
// 审计留痕(solution.trust.recheck,level 4;fire-and-forget,waitFor 收敛)
|
|
743
|
+
await vi.waitFor(() => {
|
|
744
|
+
const oplog = rows("SELECT detail FROM sys_oplog WHERE action = 'solution.trust.recheck'");
|
|
745
|
+
expect(oplog).toHaveLength(1);
|
|
746
|
+
expect(String(oplog[0]!.detail)).toContain("untrusted");
|
|
747
|
+
}, { timeout: 1000 });
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("撤销吊销(key 重入信任库)⇒ 曾被标 untrusted 的方案恢复 trusted", async () => {
|
|
751
|
+
await runInstall(makeReq());
|
|
752
|
+
await revokeKey();
|
|
753
|
+
await reconcile({ lockPath, sharedRoot, taskDir, ledgerHeadOf: async () => 3, headFloorOf: async () => 3, trustPath, state });
|
|
754
|
+
expect(state.read().solutions["demo-solution"]?.trust_state).toBe("untrusted");
|
|
755
|
+
await createTrustStore(trustPath, { audit: () => {}, fingerprintOf: pk => createHash("sha256").update(hexToBytes(pk)).digest("hex") }).add(TRUST_KEY_ID, TEST_KEY.publicKeyHex);
|
|
756
|
+
await reconcile({ lockPath, sharedRoot, taskDir, ledgerHeadOf: async () => 3, headFloorOf: async () => 3, trustPath, state });
|
|
757
|
+
expect(state.read().solutions["demo-solution"]?.trust_state).toBe("trusted");
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("P1 时代条目(无 archive)→ 无复验真源,live 核对静默跳过(不误标 untrusted)", async () => {
|
|
761
|
+
await writeLock(lockPath, { "demo-solution": "0.1.0" });
|
|
762
|
+
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000 } } });
|
|
763
|
+
await revokeKey();
|
|
764
|
+
await reconcile({ lockPath, sharedRoot, taskDir, ledgerHeadOf: async () => 3, headFloorOf: async () => 3, trustPath, state });
|
|
765
|
+
expect(state.read().solutions["demo-solution"]?.trust_state).toBeUndefined();
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
describe(".install-tasks/.lock 任务互斥(R17/R31)", () => {
|
|
770
|
+
it("并发取锁互斥:持锁期间第二取锁拒绝;释放后可再取", async () => {
|
|
771
|
+
const l1 = await acquireTaskLock(taskDir);
|
|
772
|
+
await expect(acquireTaskLock(taskDir)).rejects.toThrow(/busy|互斥/);
|
|
773
|
+
await l1.release();
|
|
774
|
+
const l2 = await acquireTaskLock(taskDir);
|
|
775
|
+
await l2.release();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("持有者崩溃后 owner 时间戳过期(>30min)→ 可接管(stale takeover)", async () => {
|
|
779
|
+
const t0 = Date.now();
|
|
780
|
+
const l1 = await acquireTaskLock(taskDir, { now: t0 });
|
|
781
|
+
// 模拟持有者崩溃:时间戳停在 31 分钟前
|
|
782
|
+
await fsp.writeFile(join(taskDir, ".lock", "owner.json"), JSON.stringify({ holder: "dead-process", acquiredAt: t0 - 31 * 60_000 }));
|
|
783
|
+
const l2 = await acquireTaskLock(taskDir, { now: t0 });
|
|
784
|
+
await l2.release();
|
|
785
|
+
void l1;
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("runInstall 撞锁 → 返回 failed 任务(busy 可重试),互斥不悬挂", async () => {
|
|
789
|
+
const l = await acquireTaskLock(taskDir);
|
|
790
|
+
const t = await runInstall(makeReq());
|
|
791
|
+
expect(t.status).toBe("failed");
|
|
792
|
+
expect(t.error?.retryable).toBe(true);
|
|
793
|
+
expect(t.message).toContain("busy");
|
|
794
|
+
expect((await getTask(taskDir, t.id))?.status).toBe("failed");
|
|
795
|
+
await l.release();
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
describe("扁平锁写(D12):.bak 备份 + 写后读回收敛(S1-3)", () => {
|
|
800
|
+
it("改写前备份原锁至 .bak(恢复 = 回拷 .bak 内容,锁条目原样可解析)", async () => {
|
|
801
|
+
await writeLock(lockPath, { auth: "0.1.0", demo: "0.1.0" });
|
|
802
|
+
await writeLock(lockPath, { auth: "0.1.0", demo: "0.2.0" });
|
|
803
|
+
expect(parseLock(await fsp.readFile(`${lockPath}.bak`, "utf8"))).toEqual({ auth: "0.1.0", demo: "0.1.0" });
|
|
804
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual({ auth: "0.1.0", demo: "0.2.0" });
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it("写后被并发写者吞写(oj build load→save 窗口)→ 读回不符重试,最终收敛", async () => {
|
|
808
|
+
const inner = nodeFsFace();
|
|
809
|
+
let sabotaged = 0;
|
|
810
|
+
g.fs = {
|
|
811
|
+
...inner,
|
|
812
|
+
rename: async (a: string, b: string) => {
|
|
813
|
+
await inner.rename(a, b);
|
|
814
|
+
if (b === lockPath && sabotaged === 0) {
|
|
815
|
+
sabotaged++;
|
|
816
|
+
await fsp.writeFile(lockPath, "auth: 0.1.0\n"); // 模拟 oj build 全量回写丢掉我们的条目
|
|
817
|
+
}
|
|
818
|
+
},
|
|
819
|
+
};
|
|
820
|
+
await writeLock(lockPath, { auth: "0.1.0" });
|
|
821
|
+
const r = await commitLockEntry(lockPath, "demo-solution", "0.1.0");
|
|
822
|
+
expect(r.ok).toBe(true);
|
|
823
|
+
expect(sabotaged).toBe(1);
|
|
824
|
+
expect(await readLock(lockPath)).toEqual({ "auth": "0.1.0", "demo-solution": "0.1.0" });
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// oj 真机实测(0.1.78,B-11):readFile/remove 对缺失目标抛 TypeError "invalid_argument"
|
|
828
|
+
// (无 .code)——首装(锁/modules.json 尚不存在)不得被 ENOENT 嗅探误判为坏锁而中断
|
|
829
|
+
it("首装锁缺失:oj 语义 TypeError invalid_argument 视同 ENOENT(isMissingPath)", async () => {
|
|
830
|
+
const ojLikeFs: FsFace = {
|
|
831
|
+
...nodeFsFace(),
|
|
832
|
+
readFile: async (path) => {
|
|
833
|
+
await fsp.access(path).then(() => undefined, () => {
|
|
834
|
+
throw new TypeError("invalid_argument");
|
|
835
|
+
});
|
|
836
|
+
return fsp.readFile(path);
|
|
837
|
+
},
|
|
838
|
+
};
|
|
839
|
+
g.fs = ojLikeFs;
|
|
840
|
+
const r = await commitLockEntry(lockPath, "demo-solution", "0.1.0");
|
|
841
|
+
expect(r.ok).toBe(true);
|
|
842
|
+
expect(await readLock(lockPath)).toEqual({ "demo-solution": "0.1.0" });
|
|
843
|
+
g.fs = nodeFsFace();
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
describe("P2-6 切换前复验(D11③/G5:sidecar 复验真源 + 启用发布方核对 + 同版本摘要比对)", () => {
|
|
848
|
+
it("install done 后 sidecar 落 archive{sha256,key_id,signature} + trust_state=trusted(复验真源随装落)", async () => {
|
|
849
|
+
const t = await runInstall(makeReq());
|
|
850
|
+
expect(t.status).toBe("done");
|
|
851
|
+
const entry = state.read().solutions["demo-solution"]!;
|
|
852
|
+
expect(entry.trust_state).toBe("trusted");
|
|
853
|
+
expect(entry.archive?.sha256).toBe(SHA256);
|
|
854
|
+
expect(entry.archive?.key_id).toBe("test.publisher");
|
|
855
|
+
expect(entry.archive?.signature).toMatchObject({ alg: "Ed25519", public_key: TEST_KEY.publicKeyHex });
|
|
856
|
+
// 落盘形状:archive 为 JSON 字符串标量(两层 YAML 约束保持),读回等价
|
|
857
|
+
const text = await fsp.readFile(join(sharedRoot, ".solutions", "state.yaml"), "utf8");
|
|
858
|
+
expect(text).toMatch(/trust_state: trusted/);
|
|
859
|
+
const reread = await createStateStore(join(sharedRoot, ".solutions", "state.yaml"));
|
|
860
|
+
expect(reread.read().solutions["demo-solution"]?.archive?.sha256).toBe(SHA256);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("同版本重装摘要不符 → verifying 拒(六码 tampered:CDN 同名版本换包即拒)", async () => {
|
|
864
|
+
await runInstall(makeReq()); // 先装一次(sidecar 落 SHA256)
|
|
865
|
+
// 重取包:包体换字节、清单摘要自洽(actual===expected 过常规比对),但与已装记录不同
|
|
866
|
+
const other = new TextEncoder().encode("replaced-archive-bytes\n");
|
|
867
|
+
const m = manifestFixture("0.1.0");
|
|
868
|
+
m.archive = { ...m.archive, sha256: createHash("sha256").update(other).digest("hex"), size: other.byteLength };
|
|
869
|
+
const t = await runInstall(makeReq({ manifest: m, fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => other.slice().buffer }) }));
|
|
870
|
+
expect(t.status).toBe("failed");
|
|
871
|
+
expect(t.stage).toBe("verifying");
|
|
872
|
+
expect(t.error?.hintKey).toBe("lifecycle.stage.verifying.tampered");
|
|
873
|
+
expect(t.stages.find(s => s.stage === "verifying")?.message).toContain("包内容被替换");
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it("吊销后启用(G5):放行 + 显著告警 + 审计带 trust + sidecar 标 untrusted", async () => {
|
|
877
|
+
await runInstall(makeReq());
|
|
878
|
+
// 吊销:信任库移除 key_id(只阻断新装/升级)
|
|
879
|
+
await createTrustStore(trustPath, { audit: () => {}, fingerprintOf: pk => createHash("sha256").update(hexToBytes(pk)).digest("hex") }).remove("test.publisher");
|
|
880
|
+
await setEnabled({ name: "demo-solution", enabled: false, sharedRoot, lockPath, state, modulesJsonPath, trustPath });
|
|
881
|
+
const r = await setEnabled({ name: "demo-solution", enabled: true, sharedRoot, lockPath, state, modulesJsonPath, trustPath });
|
|
882
|
+
expect(r.ok).toBe(true); // 放行(存量可用性承诺)
|
|
883
|
+
expect(r.message).toContain("已不在信任库");
|
|
884
|
+
expect(r.message).toContain("建议升级");
|
|
885
|
+
expect(state.read().solutions["demo-solution"]?.trust_state).toBe("untrusted");
|
|
886
|
+
// archive 真源保留(切换不丢复验依据)
|
|
887
|
+
expect(state.read().solutions["demo-solution"]?.archive?.sha256).toBe(SHA256);
|
|
888
|
+
// 审计留痕(detail.trust)
|
|
889
|
+
const oplog = rows("SELECT detail FROM sys_oplog WHERE action = 'solution.enable' ORDER BY id DESC LIMIT 1");
|
|
890
|
+
expect(String(oplog[0]?.detail)).toContain("trust");
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it("P1 时代条目(无 archive):启用放行 +「无验签真源」告警 + trust_state=absent", async () => {
|
|
894
|
+
await writeLock(lockPath, { "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
895
|
+
// 旧四行形态(P2 前安装:无 archive/trust_state 行)
|
|
896
|
+
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000 } } });
|
|
897
|
+
dbo.exec("INSERT INTO sys_solution_route (path, module, category, \"order\", deprecated, enabled) VALUES ('/demo-solution', 'demo-solution', 'main', 200, 0, 1)");
|
|
898
|
+
await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
|
|
899
|
+
await fsp.writeFile(join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml"), "name: demo-solution\nversion: 0.1.0\n");
|
|
900
|
+
await atomicWriteText(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", entry: "modules/demo-solution/0.1.0/entry.js", enabled: true }], null, 2)}\n`);
|
|
901
|
+
const r = await setEnabled({ name: "demo-solution", enabled: true, sharedRoot, lockPath, state, modulesJsonPath, trustPath });
|
|
902
|
+
expect(r.ok).toBe(true);
|
|
903
|
+
expect(r.message).toContain("无验签真源");
|
|
904
|
+
expect(state.read().solutions["demo-solution"]?.trust_state).toBe("absent");
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("吊销后升级拒:新包同 key_id 重签但 key 已移除 → needs_trust(升级走完整验签)", async () => {
|
|
908
|
+
await runInstall(makeReq());
|
|
909
|
+
await createTrustStore(trustPath, { audit: () => {}, fingerprintOf: pk => createHash("sha256").update(hexToBytes(pk)).digest("hex") }).remove("test.publisher");
|
|
910
|
+
// 0.2.0 新包(仍以 TEST_KEY 签,但 key 已吊销)
|
|
911
|
+
const up = await runInstall(makeReq({ version: "0.2.0" }));
|
|
912
|
+
expect(up.status).toBe("needs_trust");
|
|
913
|
+
expect(up.trust?.key_id).toBe("test.publisher");
|
|
914
|
+
// 已装 0.1.0 不动(吊销不动已装方案)
|
|
915
|
+
expect(state.read().solutions["demo-solution"]?.version).toBe("0.1.0");
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("发布方仍受信:启用无告警 + trust_state=trusted", async () => {
|
|
919
|
+
await runInstall(makeReq());
|
|
920
|
+
await setEnabled({ name: "demo-solution", enabled: false, sharedRoot, lockPath, state, modulesJsonPath, trustPath });
|
|
921
|
+
const r = await setEnabled({ name: "demo-solution", enabled: true, sharedRoot, lockPath, state, modulesJsonPath, trustPath });
|
|
922
|
+
expect(r.ok).toBe(true);
|
|
923
|
+
expect(r.message).not.toContain("⚠");
|
|
924
|
+
expect(state.read().solutions["demo-solution"]?.trust_state).toBe("trusted");
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
describe("setEnabled 四处同写(D12/AC-P1-6):锁摘除/恢复 + sidecar + route + modules", () => {
|
|
929
|
+
async function seedEnabled(): Promise<void> {
|
|
930
|
+
await writeLock(lockPath, { "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
931
|
+
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000 } } });
|
|
932
|
+
dbo.exec("INSERT INTO sys_solution_route (path, module, category, \"order\", deprecated, enabled) VALUES ('/demo-solution', 'demo-solution', 'main', 200, 0, 1)");
|
|
933
|
+
await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
|
|
934
|
+
await fsp.writeFile(join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml"), "name: demo-solution\nversion: 0.1.0\n");
|
|
935
|
+
await atomicWriteText(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", entry: "modules/demo-solution/0.1.0/entry.js", enabled: true }], null, 2)}\n`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
it("禁用:锁摘除(他条目保留)+ sidecar enabled=false + route enabled=0 + modules enabled=false", async () => {
|
|
939
|
+
await seedEnabled();
|
|
940
|
+
const r = await setEnabled({ name: "demo-solution", enabled: false, sharedRoot, lockPath, state, modulesJsonPath });
|
|
941
|
+
expect(r.ok).toBe(true);
|
|
942
|
+
expect(await readLock(lockPath)).toEqual({ auth: "0.1.0" });
|
|
943
|
+
expect(state.read().solutions["demo-solution"]).toMatchObject({ version: "0.1.0", enabled: false });
|
|
944
|
+
expect(Number(rows("SELECT enabled FROM sys_solution_route WHERE module = 'demo-solution'")[0]!.enabled)).toBe(0);
|
|
945
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8"));
|
|
946
|
+
expect(list[0].enabled).toBe(false);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it("启用:从 sidecar 摘除前记录恢复锁版本,四处回写 enabled=true", async () => {
|
|
950
|
+
await seedEnabled();
|
|
951
|
+
await setEnabled({ name: "demo-solution", enabled: false, sharedRoot, lockPath, state, modulesJsonPath });
|
|
952
|
+
const r = await setEnabled({ name: "demo-solution", enabled: true, sharedRoot, lockPath, state, modulesJsonPath });
|
|
953
|
+
expect(r.ok).toBe(true);
|
|
954
|
+
expect(await readLock(lockPath)).toEqual({ "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
955
|
+
expect(state.read().solutions["demo-solution"]).toMatchObject({ version: "0.1.0", enabled: true });
|
|
956
|
+
expect(Number(rows("SELECT enabled FROM sys_solution_route WHERE module = 'demo-solution'")[0]!.enabled)).toBe(1);
|
|
957
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8"));
|
|
958
|
+
expect(list[0].enabled).toBe(true);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it("摘除后锁为空 → 拒绝(空锁拒启,S1-1.2),四处状态未变", async () => {
|
|
962
|
+
await writeLock(lockPath, { "demo-solution": "0.1.0" });
|
|
963
|
+
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000 } } });
|
|
964
|
+
const r = await setEnabled({ name: "demo-solution", enabled: false, sharedRoot, lockPath, state, modulesJsonPath });
|
|
965
|
+
expect(r.ok).toBe(false);
|
|
966
|
+
expect(r.message).toContain("空锁");
|
|
967
|
+
expect(await readLock(lockPath)).toEqual({ "demo-solution": "0.1.0" });
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
describe("market_history 留痕(P1-15a 缺口 A):install/enable/disable/uninstall 四类动作齐", () => {
|
|
972
|
+
/** 「已装」种子(同 seedEnabled 形状:锁双条目避开空锁拒启 + sidecar + route + 版本目录 + modules.json) */
|
|
973
|
+
async function seedHistory(): Promise<void> {
|
|
974
|
+
await writeLock(lockPath, { "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
975
|
+
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000 } } });
|
|
976
|
+
dbo.exec("INSERT INTO sys_solution_route (path, module, category, \"order\", deprecated, enabled) VALUES ('/demo-solution', 'demo-solution', 'main', 200, 0, 1)");
|
|
977
|
+
await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
|
|
978
|
+
await atomicWriteText(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", entry: "modules/demo-solution/0.1.0/entry.js", enabled: true }], null, 2)}\n`);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
it("runInstall 终态(done)落 install 历史(守卫式:随主流程写,失败不阻断)", async () => {
|
|
982
|
+
dbo.exec(MARKET_HISTORY_DDL);
|
|
983
|
+
const t = await runInstall(makeReq());
|
|
984
|
+
expect(t.status).toBe("done");
|
|
985
|
+
const hist = rows("SELECT solution, version, action, status, operator, message FROM market_history");
|
|
986
|
+
expect(hist).toHaveLength(1);
|
|
987
|
+
expect(hist[0]).toMatchObject({ solution: "demo-solution", version: "0.1.0", action: "install", status: "done", operator: null });
|
|
988
|
+
expect(String(hist[0].message)).toContain("安装完成:demo-solution@0.1.0");
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
it("setEnabled 落 disable/enable 历史(status=success,message 与返回人话一致)", async () => {
|
|
992
|
+
dbo.exec(MARKET_HISTORY_DDL);
|
|
993
|
+
await seedHistory();
|
|
994
|
+
const off = await setEnabled({ name: "demo-solution", enabled: false, sharedRoot, lockPath, state, modulesJsonPath });
|
|
995
|
+
const on = await setEnabled({ name: "demo-solution", enabled: true, sharedRoot, lockPath, state, modulesJsonPath });
|
|
996
|
+
expect(off.ok).toBe(true);
|
|
997
|
+
expect(on.ok).toBe(true);
|
|
998
|
+
const hist = rows("SELECT action, status, message FROM market_history ORDER BY id");
|
|
999
|
+
expect(hist).toEqual([
|
|
1000
|
+
{ action: "disable", status: "success", message: off.message },
|
|
1001
|
+
{ action: "enable", status: "success", message: on.message },
|
|
1002
|
+
]);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it("market_history 表不存在(站点未装市场方案)→ setEnabled 仍 ok:true(守卫式留痕静默跳过)", async () => {
|
|
1006
|
+
await seedHistory();
|
|
1007
|
+
const r = await setEnabled({ name: "demo-solution", enabled: false, sharedRoot, lockPath, state, modulesJsonPath });
|
|
1008
|
+
expect(r.ok).toBe(true);
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
describe("任务治理(R13/B9/R31):保留 10 条 + 超时判 failed + message 脱敏", () => {
|
|
1013
|
+
it("任务文件保留最近 10 条,超出删最旧(按 startedAt 序)", async () => {
|
|
1014
|
+
const base = Date.now();
|
|
1015
|
+
for (let i = 0; i < 12; i++) {
|
|
1016
|
+
await writeTask(taskDir, {
|
|
1017
|
+
id: `t-${String(i).padStart(2, "0")}`,
|
|
1018
|
+
name: "demo-solution",
|
|
1019
|
+
version: "0.1.0",
|
|
1020
|
+
status: "done",
|
|
1021
|
+
stage: "done",
|
|
1022
|
+
progress: 100,
|
|
1023
|
+
message: `m${i}`,
|
|
1024
|
+
stages: [],
|
|
1025
|
+
startedAt: base + i,
|
|
1026
|
+
finishedAt: base + i,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
const files = (await fsp.readdir(taskDir)).filter(f => f.endsWith(".json"));
|
|
1030
|
+
expect(files).toHaveLength(10);
|
|
1031
|
+
await expect(fsp.access(join(taskDir, "t-01.json"))).rejects.toThrow();
|
|
1032
|
+
await fsp.access(join(taskDir, "t-11.json"));
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it("sweepTimedOut:running 超 30min 判 failed(retryable),新鲜 running 不动", async () => {
|
|
1036
|
+
const now = Date.now();
|
|
1037
|
+
await writeTask(taskDir, {
|
|
1038
|
+
id: "old",
|
|
1039
|
+
name: "demo-solution",
|
|
1040
|
+
version: "0.1.0",
|
|
1041
|
+
status: "running",
|
|
1042
|
+
stage: "migrating",
|
|
1043
|
+
progress: 30,
|
|
1044
|
+
message: "执行中",
|
|
1045
|
+
stages: [],
|
|
1046
|
+
startedAt: now - 31 * 60_000,
|
|
1047
|
+
});
|
|
1048
|
+
await writeTask(taskDir, {
|
|
1049
|
+
id: "fresh",
|
|
1050
|
+
name: "demo-solution",
|
|
1051
|
+
version: "0.1.0",
|
|
1052
|
+
status: "running",
|
|
1053
|
+
stage: "preflight",
|
|
1054
|
+
progress: 0,
|
|
1055
|
+
message: "执行中",
|
|
1056
|
+
stages: [],
|
|
1057
|
+
startedAt: now - 1000,
|
|
1058
|
+
});
|
|
1059
|
+
const flipped = await sweepTimedOut(taskDir, now);
|
|
1060
|
+
expect(flipped.map(x => x.id)).toEqual(["old"]);
|
|
1061
|
+
expect((await getTask(taskDir, "old"))?.status).toBe("failed");
|
|
1062
|
+
expect((await getTask(taskDir, "old"))?.error?.retryable).toBe(true);
|
|
1063
|
+
expect((await getTask(taskDir, "fresh"))?.status).toBe("running");
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it("redact:URL query / Bearer / JWT / token=xxx 脱敏;sha256 诊断摘要保留(verify 可诊断性)", () => {
|
|
1067
|
+
expect(redact("下载失败:https://cdn.example.com/a.tgz?token=secret123&sig=zzz"))
|
|
1068
|
+
.not
|
|
1069
|
+
.toContain("secret123");
|
|
1070
|
+
expect(redact("凭证 Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig")).not.toContain("eyJhbGciOiJIUzI1NiJ9");
|
|
1071
|
+
expect(redact("参数 password=hunter2 其他")).not.toContain("hunter2");
|
|
1072
|
+
const digest = `校验失败:期望 ${SHA256},实得 ${"f".repeat(64)}`;
|
|
1073
|
+
expect(redact(digest)).toBe(digest);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it("redact(P2-5):PEM 私钥块 + private_key JSON 字段整段脱敏", () => {
|
|
1077
|
+
const pem = "BEGIN 头-----BEGIN EC PRIVATE KEY-----\nabcdef0123456789\n-----END EC PRIVATE KEY-----尾巴";
|
|
1078
|
+
expect(redact(pem)).toContain("-----BEGIN PRIVATE KEY-----…-----END PRIVATE KEY-----");
|
|
1079
|
+
expect(redact(pem)).not.toContain("abcdef0123456789");
|
|
1080
|
+
expect(redact("{\"private_key\":\"c2VjcmV0LWtleS1ieXRlcw\",\"key_id\":\"acme.market\"}")).toContain("\"private_key\":\"…\"");
|
|
1081
|
+
// 钉用例:key_id 与指纹明文保留(结构化字段,不进 message 被吞;脱敏只针对私钥/口令)
|
|
1082
|
+
const msg = "发布方 acme.market(指纹 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef)签名不符";
|
|
1083
|
+
const out = redact(msg);
|
|
1084
|
+
expect(out).toContain("acme.market");
|
|
1085
|
+
expect(out).toContain("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
|
1086
|
+
expect(out).toBe(msg);
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it("writeTask 落盘前脱敏:message/error.reason/stages 逐条过 redact", async () => {
|
|
1090
|
+
await writeTask(taskDir, {
|
|
1091
|
+
id: "leak",
|
|
1092
|
+
name: "demo-solution",
|
|
1093
|
+
version: "0.1.0",
|
|
1094
|
+
status: "failed",
|
|
1095
|
+
stage: "downloading",
|
|
1096
|
+
progress: 10,
|
|
1097
|
+
message: "下载失败:https://cdn.example.com/a.tgz?token=secret123",
|
|
1098
|
+
stages: [{ stage: "downloading", ok: false, message: "URL https://cdn.example.com/a.tgz?token=secret123 失败" }],
|
|
1099
|
+
startedAt: 1,
|
|
1100
|
+
finishedAt: 2,
|
|
1101
|
+
error: { reason: "Bearer abcdef123456 泄漏", retryable: true, hintKey: "lifecycle.stage.downloading.hint" },
|
|
1102
|
+
});
|
|
1103
|
+
const raw = await fsp.readFile(join(taskDir, "leak.json"), "utf8");
|
|
1104
|
+
expect(raw).not.toContain("secret123");
|
|
1105
|
+
expect(raw).not.toContain("abcdef123456");
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
describe("P2-10 TaskRecord schemaVersion:写方盖章 + 读方回填(旧记录不炸)", () => {
|
|
1110
|
+
it("writeTask 盖章 schemaVersion=1(写方语义:单写者恒为当前版本)", async () => {
|
|
1111
|
+
await writeTask(taskDir, { id: "t-stamp", name: "demo-solution", version: "0.1.0", status: "done", stage: "done", progress: 100, message: "", stages: [], startedAt: 1 });
|
|
1112
|
+
const raw = JSON.parse(await fsp.readFile(join(taskDir, "t-stamp.json"), "utf8")) as { schemaVersion?: number };
|
|
1113
|
+
expect(raw.schemaVersion).toBe(1);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it("readTasks 回填:无 schemaVersion 的旧记录读出 = 1", async () => {
|
|
1117
|
+
await fsp.mkdir(taskDir, { recursive: true });
|
|
1118
|
+
await fsp.writeFile(join(taskDir, "t-old.json"), JSON.stringify({ id: "t-old", name: "demo-solution", version: "0.1.0", status: "done", stage: "done", progress: 100, message: "", stages: [], startedAt: 1 }));
|
|
1119
|
+
const all = await readTasks(taskDir);
|
|
1120
|
+
expect(all).toHaveLength(1);
|
|
1121
|
+
expect(all[0]?.id).toBe("t-old");
|
|
1122
|
+
expect(all[0]?.schemaVersion).toBe(1);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it("getTask 回填:旧记录读出 = 1;高版本(99)原样透传不剥(降级提示属消费面)", async () => {
|
|
1126
|
+
await fsp.mkdir(taskDir, { recursive: true });
|
|
1127
|
+
await fsp.writeFile(join(taskDir, "t-old.json"), JSON.stringify({ id: "t-old", name: "demo-solution", version: "0.1.0", status: "done", stage: "done", progress: 100, message: "", stages: [], startedAt: 1 }));
|
|
1128
|
+
expect((await getTask(taskDir, "t-old"))?.schemaVersion).toBe(1);
|
|
1129
|
+
await fsp.writeFile(join(taskDir, "t-future.json"), JSON.stringify({ id: "t-future", name: "demo-solution", version: "0.2.0", status: "done", stage: "done", progress: 100, message: "", stages: [], startedAt: 2, schemaVersion: 99 }));
|
|
1130
|
+
expect((await getTask(taskDir, "t-future"))?.schemaVersion).toBe(99);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it("sweepTimedOut 改判写回:旧记录(无 schemaVersion)经回填读出后写回盖章为当前版本", async () => {
|
|
1134
|
+
const now = Date.now();
|
|
1135
|
+
await fsp.mkdir(taskDir, { recursive: true });
|
|
1136
|
+
await fsp.writeFile(join(taskDir, "t-stale.json"), JSON.stringify({ id: "t-stale", name: "demo-solution", version: "0.1.0", status: "running", stage: "migrating", progress: 30, message: "", stages: [], startedAt: now - 31 * 60_000 }));
|
|
1137
|
+
const flipped = await sweepTimedOut(taskDir, now);
|
|
1138
|
+
expect(flipped).toHaveLength(1);
|
|
1139
|
+
const raw = JSON.parse(await fsp.readFile(join(taskDir, "t-stale.json"), "utf8")) as { schemaVersion?: number };
|
|
1140
|
+
expect(raw.schemaVersion).toBe(1);
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
describe("P1-16 接线:reconcile 不一致自动修复(重跑 setEnabled)+ operator 通道 + migrate opts 转发", () => {
|
|
1145
|
+
it("route enabled=0 / modules.json enabled=false 分歧 + 注入 state → 自动重跑 setEnabled(true) 收敛(healed 记 enable)", async () => {
|
|
1146
|
+
await writeLock(lockPath, { "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
1147
|
+
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000 } } });
|
|
1148
|
+
dbo.exec("INSERT INTO sys_solution_route (path, module, category, \"order\", deprecated, enabled) VALUES ('/demo-solution', 'demo-solution', 'main', 200, 0, 0)");
|
|
1149
|
+
await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
|
|
1150
|
+
await atomicWriteText(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", entry: "modules/demo-solution/0.1.0/entry.js", enabled: false }], null, 2)}\n`);
|
|
1151
|
+
const report = await reconcile({
|
|
1152
|
+
lockPath,
|
|
1153
|
+
sharedRoot,
|
|
1154
|
+
taskDir,
|
|
1155
|
+
modulesJsonPath,
|
|
1156
|
+
routeEnabledOf: async name => (name === "demo-solution" ? false : null),
|
|
1157
|
+
ledgerHeadOf: async () => null,
|
|
1158
|
+
headFloorOf: async () => null,
|
|
1159
|
+
state,
|
|
1160
|
+
});
|
|
1161
|
+
expect(report.healed).toEqual([expect.objectContaining({ name: "demo-solution", action: "enable" })]);
|
|
1162
|
+
// 四处一致收敛:route=1、modules.json enabled=true、sidecar enabled=true、锁条目原样
|
|
1163
|
+
expect(dbo.prepare("SELECT enabled FROM sys_solution_route WHERE module = ?").get("demo-solution")).toMatchObject({ enabled: 1 });
|
|
1164
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as Array<{ name: string, enabled: boolean }>;
|
|
1165
|
+
expect(list[0]).toMatchObject({ name: "demo-solution", enabled: true });
|
|
1166
|
+
expect(state.read().solutions["demo-solution"]).toMatchObject({ enabled: true });
|
|
1167
|
+
expect(await readLock(lockPath)).toEqual({ "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
1168
|
+
expect(report.inconsistencies.filter(i => i.name === "demo-solution")).toHaveLength(0);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it("未注入 state(缺省)→ 维持只报不修(P1-10 既有语义不变)", async () => {
|
|
1172
|
+
await writeLock(lockPath, { "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
1173
|
+
await atomicWriteText(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", enabled: false }], null, 2)}\n`);
|
|
1174
|
+
const report = await reconcile({
|
|
1175
|
+
lockPath,
|
|
1176
|
+
sharedRoot,
|
|
1177
|
+
taskDir,
|
|
1178
|
+
modulesJsonPath,
|
|
1179
|
+
routeEnabledOf: async name => (name === "demo-solution" ? false : null),
|
|
1180
|
+
ledgerHeadOf: async () => null,
|
|
1181
|
+
headFloorOf: async () => null,
|
|
1182
|
+
});
|
|
1183
|
+
expect(report.healed).toEqual([]);
|
|
1184
|
+
expect(report.inconsistencies.filter(i => i.name === "demo-solution")).toHaveLength(2);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("operator 通道:runInstall / setEnabled 的 operator 落 market_history(缺省 null 语义不变)", async () => {
|
|
1188
|
+
dbo.exec(MARKET_HISTORY_DDL);
|
|
1189
|
+
await writeLock(lockPath, { "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
1190
|
+
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000 } } });
|
|
1191
|
+
await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
|
|
1192
|
+
const t = await runInstall(makeReq({ operator: "admin" }));
|
|
1193
|
+
expect(t.status).toBe("done");
|
|
1194
|
+
const on = await setEnabled({ name: "demo-solution", enabled: true, sharedRoot, lockPath, state, modulesJsonPath, operator: "admin" });
|
|
1195
|
+
expect(on.ok).toBe(true);
|
|
1196
|
+
const hist = rows("SELECT action, operator FROM market_history ORDER BY id");
|
|
1197
|
+
expect(hist).toEqual([
|
|
1198
|
+
{ action: "install", operator: "admin" },
|
|
1199
|
+
{ action: "enable", operator: "admin" },
|
|
1200
|
+
]);
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
it("captureMigrateExec 转发 opts:migrate 段 exec.run 收到 {timeoutMs: 600000}(生产 exec 包装不丢超时)", async () => {
|
|
1204
|
+
const seen: Array<{ argv: string[], opts?: { timeoutMs?: number } }> = [];
|
|
1205
|
+
const base = bundleExec();
|
|
1206
|
+
const exec: ExecFace = {
|
|
1207
|
+
run: (argv, opts) => {
|
|
1208
|
+
seen.push({ argv, opts });
|
|
1209
|
+
return base.run(argv, opts);
|
|
1210
|
+
},
|
|
1211
|
+
};
|
|
1212
|
+
const t = await runInstall(makeReq({ exec }));
|
|
1213
|
+
expect(t.status).toBe("done");
|
|
1214
|
+
const mig = seen.find(c => c.argv[0] === "oj" && c.argv[1] === "migrate");
|
|
1215
|
+
expect(mig).toBeDefined();
|
|
1216
|
+
expect(mig?.opts).toEqual({ timeoutMs: 600000 });
|
|
1217
|
+
});
|
|
1218
|
+
});
|