@oj-module/cli 0.2.2 → 0.2.5
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 +147 -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 +56 -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,386 @@
|
|
|
1
|
+
// BDD:卸载编排(P1-15a,R9/AC-P1-6 反向/D12/AC2-6/S1-1.2)。
|
|
2
|
+
// 端点(createMarketApi 的 uninstall-impact / uninstall)只做门禁 + 取参 + 调 runUninstall;
|
|
3
|
+
// 编排器负责四处同删(锁/sidecar/sys_solution_route/modules.json)+ 权限级联 uninstallSolution
|
|
4
|
+
// + 版本目录移除 + 审计 + 全用户失效 + market_history 留痕。本规格为验收锚点。
|
|
5
|
+
// 夹具:本仓范式(node:sqlite 真实迁移建表 + 真实 fs 面 g.fs);market_history 表真源随市场
|
|
6
|
+
// 方案包(fixture.MARKET_HISTORY_DDL 同构建表),守卫语义(表不存在静默跳过)单列一例。
|
|
7
|
+
/* eslint-disable test/prefer-lowercase-title -- 端点动词/路径是验收锚点,保留大写(market-api.spec 同约定) */
|
|
8
|
+
import type { FsFace } from "../../src/_platform/_lifecycle/atomic";
|
|
9
|
+
import type { MarketEnv } from "../../src/_platform/market/api";
|
|
10
|
+
import * as fsp from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
14
|
+
import { readLock, writeLock } from "../../src/_platform/_lifecycle/lock";
|
|
15
|
+
import { createStateStore } from "../../src/_platform/_lifecycle/state";
|
|
16
|
+
import { catalogSpec, collectRetainedTables, tablePrefixPattern } from "../../src/_platform/_lifecycle/uninstall";
|
|
17
|
+
import { invalidateAllUsers } from "../../src/_platform/_shared/service/access";
|
|
18
|
+
import { createMarketApi } from "../../src/_platform/market/api";
|
|
19
|
+
import { installDb, MARKET_HISTORY_DDL } from "../fixture";
|
|
20
|
+
|
|
21
|
+
const g = globalThis as any;
|
|
22
|
+
|
|
23
|
+
let root: string;
|
|
24
|
+
let sharedRoot: string;
|
|
25
|
+
let statePath: string;
|
|
26
|
+
let modulesJsonPath: string;
|
|
27
|
+
let dbo: any;
|
|
28
|
+
let last: { status: number, msg?: string, data?: unknown } | null = null;
|
|
29
|
+
|
|
30
|
+
function nodeFsFace(): FsFace {
|
|
31
|
+
return {
|
|
32
|
+
writeFile: (path, data) => fsp.writeFile(path, data),
|
|
33
|
+
readFile: path => fsp.readFile(path),
|
|
34
|
+
mkdir: (path, opts) => fsp.mkdir(path, opts),
|
|
35
|
+
remove: (path, opts) => fsp.rm(path, opts),
|
|
36
|
+
rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
|
|
37
|
+
copyFile: (src, dest) => fsp.copyFile(src, dest),
|
|
38
|
+
readdir: path => fsp.readdir(path),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeEnv(overrides: Partial<MarketEnv> = {}): MarketEnv {
|
|
43
|
+
return {
|
|
44
|
+
sharedRoot,
|
|
45
|
+
staging: join(sharedRoot, ".staging"),
|
|
46
|
+
taskDir: join(sharedRoot, ".install-tasks"),
|
|
47
|
+
statePath,
|
|
48
|
+
dbPath: join(root, "db.sqlite"),
|
|
49
|
+
siteModulesDir: join(root, "dist", "modules"),
|
|
50
|
+
modulesJsonPath,
|
|
51
|
+
apiPrefix: "/api/demo-solution",
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 预置「已装」形态:锁(含 other 以避开空锁拒启)+ sidecar + 版本目录 + modules.json + route + 角色/用户绑定 + market_history 表 */
|
|
57
|
+
async function seedInstalled(opts: { withOther?: boolean } = {}): Promise<void> {
|
|
58
|
+
const withOther = opts.withOther !== false;
|
|
59
|
+
const lock: Record<string, string> = { "demo-solution": "0.1.0" };
|
|
60
|
+
if (withOther)
|
|
61
|
+
lock.other = "1.0.0";
|
|
62
|
+
await writeLock(join(sharedRoot, "manifests.yaml"), lock);
|
|
63
|
+
const state = await createStateStore(statePath);
|
|
64
|
+
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1 } } });
|
|
65
|
+
await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
|
|
66
|
+
await fsp.writeFile(modulesJsonPath, JSON.stringify([
|
|
67
|
+
{ name: "demo-solution", enabled: true, src: "modules/demo-solution/" },
|
|
68
|
+
...(withOther ? [{ name: "other", enabled: true, src: "modules/other/" }] : []),
|
|
69
|
+
]));
|
|
70
|
+
dbo.exec(MARKET_HISTORY_DDL);
|
|
71
|
+
dbo.prepare("INSERT INTO sys_solution_route (path, module, category) VALUES (?, ?, ?)").run("/demo-solution", "demo-solution", "main");
|
|
72
|
+
dbo.prepare("INSERT INTO sys_role (name, module, title, builtin) VALUES (?, ?, ?, 0)").run("demo-solution.admin", "demo-solution", "管理员");
|
|
73
|
+
dbo.prepare("INSERT INTO sys_role (name, module, title, builtin) VALUES (?, ?, ?, 0)").run("demo-solution.viewer", "demo-solution", "访客");
|
|
74
|
+
dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "demo-solution.admin");
|
|
75
|
+
dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(2, "demo-solution.viewer");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 在 seedInstalled 之上:manifest.tables 声明业务表 + 实际建表(Q4 须保留、不删) */
|
|
79
|
+
async function seedInstalledWithTables(opts: { withOther?: boolean } = {}): Promise<void> {
|
|
80
|
+
await seedInstalled(opts);
|
|
81
|
+
await fsp.writeFile(
|
|
82
|
+
join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml"),
|
|
83
|
+
"name: demo-solution\nversion: 0.1.0\ntables:\n - demo_solution_notes\n - demo_solution_tags\n",
|
|
84
|
+
);
|
|
85
|
+
dbo.exec("CREATE TABLE demo_solution_notes (id INTEGER PRIMARY KEY, body TEXT)");
|
|
86
|
+
dbo.exec("CREATE TABLE demo_solution_tags (id INTEGER PRIMARY KEY, name TEXT)");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
root = await fsp.mkdtemp(join(tmpdir(), "ojm-uninstall-"));
|
|
91
|
+
sharedRoot = join(root, "modules");
|
|
92
|
+
statePath = join(sharedRoot, ".solutions", "state.yaml");
|
|
93
|
+
modulesJsonPath = join(root, "dist", "modules.json");
|
|
94
|
+
await fsp.mkdir(sharedRoot, { recursive: true });
|
|
95
|
+
await fsp.mkdir(dirname(modulesJsonPath), { recursive: true });
|
|
96
|
+
g.fs = nodeFsFace();
|
|
97
|
+
dbo = installDb();
|
|
98
|
+
dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
|
|
99
|
+
g.json = {
|
|
100
|
+
ok: (data: unknown) => {
|
|
101
|
+
last = { status: 200, data };
|
|
102
|
+
},
|
|
103
|
+
fail: (status: number, msg: string) => {
|
|
104
|
+
last = { status, msg };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
last = null;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(async () => {
|
|
111
|
+
g.fs = undefined;
|
|
112
|
+
g.http = undefined;
|
|
113
|
+
g.json = undefined;
|
|
114
|
+
g.db = undefined;
|
|
115
|
+
invalidateAllUsers();
|
|
116
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("GET uninstall-impact 影响面预检(superadmin,R9)", () => {
|
|
120
|
+
it("返回受影响角色数与用户数(供前端二次确认)", async () => {
|
|
121
|
+
await seedInstalled();
|
|
122
|
+
const api = createMarketApi(makeEnv());
|
|
123
|
+
g.http = { user: { id: 1 }, query: { name: "demo-solution" } };
|
|
124
|
+
await api["uninstall-impact"].get();
|
|
125
|
+
expect(last?.status).toBe(200);
|
|
126
|
+
expect(last?.data).toMatchObject({ name: "demo-solution", affectedRoles: 2, affectedUsers: 2 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("未安装方案也返回 0/0(不报错)", async () => {
|
|
130
|
+
const api = createMarketApi(makeEnv());
|
|
131
|
+
g.http = { user: { id: 1 }, query: { name: "nope" } };
|
|
132
|
+
await api["uninstall-impact"].get();
|
|
133
|
+
expect(last?.status).toBe(200);
|
|
134
|
+
expect(last?.data).toMatchObject({ name: "nope", affectedRoles: 0, affectedUsers: 0 });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("POST uninstall 卸载编排(superadmin,R9)", () => {
|
|
139
|
+
it("四处同删 + 权限级联 + 版本目录移除 + 历史留痕,返回 ok:true", async () => {
|
|
140
|
+
await seedInstalled();
|
|
141
|
+
const api = createMarketApi(makeEnv());
|
|
142
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
|
|
143
|
+
await api.uninstall.post();
|
|
144
|
+
expect(last?.status).toBe(200);
|
|
145
|
+
expect(last?.data).toMatchObject({ ok: true });
|
|
146
|
+
// ① 锁摘除(other 条目保留——空锁拒启防线未被波及)
|
|
147
|
+
expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ other: "1.0.0" });
|
|
148
|
+
// ② sidecar 删条目
|
|
149
|
+
const state = await createStateStore(statePath);
|
|
150
|
+
expect(state.read().solutions["demo-solution"]).toBeUndefined();
|
|
151
|
+
// ③ sys_solution_route 删
|
|
152
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sys_solution_route WHERE module = ?").get("demo-solution").c)).toBe(0);
|
|
153
|
+
// ④ modules.json 删条目(other 保留)
|
|
154
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as Array<{ name: string }>;
|
|
155
|
+
expect(list.find(e => e.name === "demo-solution")).toBeUndefined();
|
|
156
|
+
expect(list.find(e => e.name === "other")).toBeDefined();
|
|
157
|
+
// 权限唯一减法出口:双维度级联(角色 + 用户绑定 + 权限点)
|
|
158
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sys_role WHERE module = ?").get("demo-solution").c)).toBe(0);
|
|
159
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sys_user_role WHERE role_name = ?").get("demo-solution.admin").c)).toBe(0);
|
|
160
|
+
// 版本目录移除(缺失即抛,断言 reject)
|
|
161
|
+
await expect(fsp.readdir(join(sharedRoot, "demo-solution-0.1.0"))).rejects.toBeTruthy();
|
|
162
|
+
// 历史留痕(第一方市场应用自身留痕;operator 反查 users 表)
|
|
163
|
+
const hist = dbo.prepare("SELECT * FROM market_history WHERE solution = ? AND action = ?").all("demo-solution", "uninstall");
|
|
164
|
+
expect(hist).toHaveLength(1);
|
|
165
|
+
expect(String(hist[0].status)).toBe("success");
|
|
166
|
+
expect(String(hist[0].operator)).toBe("admin");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("未安装方案 → 404 资源缺失语义(P3-11:非 200 {ok:false}),人话在 message", async () => {
|
|
170
|
+
const api = createMarketApi(makeEnv());
|
|
171
|
+
g.http = { user: { id: 1 }, body: { name: "nope" } };
|
|
172
|
+
await api.uninstall.post();
|
|
173
|
+
expect(last?.status).toBe(404);
|
|
174
|
+
expect(String(last?.msg)).toContain("未安装");
|
|
175
|
+
// 资源缺失:锁/sidecar/目录零改动(404 即什么都没发生)
|
|
176
|
+
expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("卸载最后一个模块 → ok:false(空锁拒启 S1-1.2),锁保持不变", async () => {
|
|
180
|
+
await seedInstalled({ withOther: false });
|
|
181
|
+
const api = createMarketApi(makeEnv());
|
|
182
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
|
|
183
|
+
await api.uninstall.post();
|
|
184
|
+
expect(last?.status).toBe(200);
|
|
185
|
+
expect(last?.data).toMatchObject({ ok: false });
|
|
186
|
+
expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ "demo-solution": "0.1.0" });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("market_history 表不存在(站点未装市场方案)→ 卸载仍 ok:true(守卫式留痕静默跳过,缺口 A)", async () => {
|
|
190
|
+
await seedInstalled();
|
|
191
|
+
dbo.exec("DROP TABLE market_history");
|
|
192
|
+
const api = createMarketApi(makeEnv());
|
|
193
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
|
|
194
|
+
await api.uninstall.post();
|
|
195
|
+
expect(last?.status).toBe(200);
|
|
196
|
+
expect(last?.data).toMatchObject({ ok: true });
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("P3-3 卸载级联单一事务(fail-closed:中段失败整段回滚、锁不摘、无半残)", () => {
|
|
201
|
+
it("权限级联中段失败 → 事务回滚(route/角色/权限零残)、锁/sidecar/modules/版本目录原样保留", async () => {
|
|
202
|
+
await seedInstalled();
|
|
203
|
+
// 注入:db.tx 内命中「delete from sys_role where module」时抛错,模拟级联中段失败;自建 BEGIN/ROLLBACK 保证回滚
|
|
204
|
+
const origTx = g.db.tx;
|
|
205
|
+
g.db.tx = async (fn: (t: { exec: (sql: string, params?: unknown[]) => unknown, query: (sql: string, params?: unknown[]) => unknown }) => unknown) => {
|
|
206
|
+
dbo.exec("BEGIN");
|
|
207
|
+
const t = {
|
|
208
|
+
query: (sql: string, params: unknown[] = []) => dbo.prepare(sql).all(...(params as never[])),
|
|
209
|
+
exec: (sql: string, params: unknown[] = []) => {
|
|
210
|
+
if (/delete from sys_role where module/.test(sql))
|
|
211
|
+
throw new Error("injected cascade failure");
|
|
212
|
+
return Number(dbo.prepare(sql).run(...(params as never[])).changes);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
const r = await fn(t);
|
|
217
|
+
dbo.exec("COMMIT");
|
|
218
|
+
return r;
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
dbo.exec("ROLLBACK");
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const api = createMarketApi(makeEnv());
|
|
226
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
|
|
227
|
+
await api.uninstall.post();
|
|
228
|
+
expect(last?.status).toBe(200);
|
|
229
|
+
expect(last?.data).toMatchObject({ ok: false });
|
|
230
|
+
// 锁仍在(fail-closed:模块仍挂载,可重试或重装自愈)
|
|
231
|
+
expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ "demo-solution": "0.1.0", "other": "1.0.0" });
|
|
232
|
+
// 事务回滚:route / 角色 / 用户绑定 零残(全部回退到卸载前)
|
|
233
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sys_solution_route WHERE module = ?").get("demo-solution").c)).toBe(1);
|
|
234
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sys_role WHERE module = ?").get("demo-solution").c)).toBe(2);
|
|
235
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sys_user_role WHERE role_name = ?").get("demo-solution.admin").c)).toBe(1);
|
|
236
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sys_user_role WHERE role_name = ?").get("demo-solution.viewer").c)).toBe(1);
|
|
237
|
+
// 文件操作未触发(级联失败在锁摘除之前返回):sidecar / modules.json / 版本目录 原样保留
|
|
238
|
+
const state = await createStateStore(statePath);
|
|
239
|
+
expect(state.read().solutions["demo-solution"]).toMatchObject({ version: "0.1.0" });
|
|
240
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as Array<{ name: string }>;
|
|
241
|
+
expect(list.find(e => e.name === "demo-solution")).toBeDefined();
|
|
242
|
+
expect(list.find(e => e.name === "other")).toBeDefined();
|
|
243
|
+
await fsp.access(join(sharedRoot, "demo-solution-0.1.0"));
|
|
244
|
+
// 还原 tx(不影响后续用例)
|
|
245
|
+
g.db.tx = origTx;
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("门禁(R9:卸载 superadmin 独占)", () => {
|
|
250
|
+
it("非 superadmin 访问 uninstall / uninstall-impact → 403", async () => {
|
|
251
|
+
await seedInstalled();
|
|
252
|
+
const api = createMarketApi(makeEnv());
|
|
253
|
+
g.http = { user: { id: 2 }, query: { name: "demo-solution" } };
|
|
254
|
+
await api["uninstall-impact"].get();
|
|
255
|
+
expect(last?.status).toBe(403);
|
|
256
|
+
g.http = { user: { id: 2 }, body: { name: "demo-solution" } };
|
|
257
|
+
await api.uninstall.post();
|
|
258
|
+
expect(last?.status).toBe(403);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("P3-4 业务表保留提示(Q4:不删业务数据,retained 回传 + 表仍在)", () => {
|
|
263
|
+
it("卸载成功响应含 retained 非空,且业务表仍在(sqlite 直读);代码/锁/路由已删", async () => {
|
|
264
|
+
await seedInstalledWithTables();
|
|
265
|
+
const api = createMarketApi(makeEnv());
|
|
266
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
|
|
267
|
+
await api.uninstall.post();
|
|
268
|
+
expect(last?.status).toBe(200);
|
|
269
|
+
const data = last?.data as { ok: boolean, retained?: string[] };
|
|
270
|
+
expect(data).toMatchObject({ ok: true });
|
|
271
|
+
expect(Array.isArray(data.retained)).toBe(true);
|
|
272
|
+
expect(data.retained).toContain("demo_solution_notes");
|
|
273
|
+
expect(data.retained).toContain("demo_solution_tags");
|
|
274
|
+
// Q4:业务表未 DROP(sqlite_master 直读仍在)
|
|
275
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name = ?").get("demo_solution_notes").c)).toBe(1);
|
|
276
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name = ?").get("demo_solution_tags").c)).toBe(1);
|
|
277
|
+
// 既有纪律:锁摘除 + sidecar + route + modules 已删(other 保留)
|
|
278
|
+
expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ other: "1.0.0" });
|
|
279
|
+
const state = await createStateStore(statePath);
|
|
280
|
+
expect(state.read().solutions["demo-solution"]).toBeUndefined();
|
|
281
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sys_solution_route WHERE module = ?").get("demo-solution").c)).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("manifest 缺失(api-only 方案)→ 前缀兜底仍能回传业务表", async () => {
|
|
285
|
+
await seedInstalled();
|
|
286
|
+
// 不写 manifest,但业务表名匹配模块前缀 demo_solution.*(兜底扫描命中)
|
|
287
|
+
dbo.exec("CREATE TABLE demo_solution_orphan (id INTEGER)");
|
|
288
|
+
const api = createMarketApi(makeEnv());
|
|
289
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
|
|
290
|
+
await api.uninstall.post();
|
|
291
|
+
const data = last?.data as { ok: boolean, retained?: string[] };
|
|
292
|
+
expect(data).toMatchObject({ ok: true });
|
|
293
|
+
expect(data.retained).toContain("demo_solution_orphan");
|
|
294
|
+
expect(Number(dbo.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name = ?").get("demo_solution_orphan").c)).toBe(1);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("P4-1 多方言卸载矩阵(P3-4#2):catalogSpec 三方言 SQL 形态 + 前缀转义方言无关", () => {
|
|
299
|
+
it("catalogSpec 三方言 existing SQL 形态正确(sqlite=sqlite_master / mysql/pg=information_schema)", () => {
|
|
300
|
+
const sqlite = catalogSpec("sqlite");
|
|
301
|
+
const mysql = catalogSpec("mysql");
|
|
302
|
+
const pg = catalogSpec("pg");
|
|
303
|
+
expect(sqlite.existing).toBe("SELECT name FROM sqlite_master WHERE type='table'");
|
|
304
|
+
expect(mysql.existing).toContain("information_schema.tables");
|
|
305
|
+
expect(mysql.existing).toContain("DATABASE()");
|
|
306
|
+
expect(pg.existing).toContain("information_schema.tables");
|
|
307
|
+
expect(pg.existing).toContain("table_schema = 'public'");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("catalogSpec.like 三方言均带 ESCAPE '!' + 正确 schema 表达式 + 参数化 pat", () => {
|
|
311
|
+
const pat = "demo_solution_%";
|
|
312
|
+
const sqlite = catalogSpec("sqlite").like(pat);
|
|
313
|
+
const mysql = catalogSpec("mysql").like(pat);
|
|
314
|
+
const pg = catalogSpec("pg").like(pat);
|
|
315
|
+
// sqlite
|
|
316
|
+
expect(sqlite[0]).toBe("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? ESCAPE '!'");
|
|
317
|
+
expect(sqlite[1]).toEqual([pat]);
|
|
318
|
+
// mysql:information_schema + DATABASE() + ESCAPE + 参数化(值不拼字符串,SSRF/注入无关但口径统一)
|
|
319
|
+
expect(mysql[0]).toContain("information_schema.tables");
|
|
320
|
+
expect(mysql[0]).toContain("DATABASE()");
|
|
321
|
+
expect(mysql[0]).toContain("ESCAPE '!'");
|
|
322
|
+
expect(mysql[1]).toEqual([pat]);
|
|
323
|
+
// pg:information_schema + 'public' + ESCAPE + 参数化
|
|
324
|
+
expect(pg[0]).toContain("information_schema.tables");
|
|
325
|
+
expect(pg[0]).toContain("table_schema = 'public'");
|
|
326
|
+
expect(pg[0]).toContain("ESCAPE '!'");
|
|
327
|
+
expect(pg[1]).toEqual([pat]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("tablePrefixPattern 转义 !%_ 且连字符→下划线作表前缀", () => {
|
|
331
|
+
// 连字符→下划线后,该下划线亦被转义(!_)= 字面分隔符而非 LIKE 通配,避免前缀扫描越权命中
|
|
332
|
+
expect(tablePrefixPattern("demo-solution")).toBe("demo!_solution_%");
|
|
333
|
+
// 危险字符必须转义,避免 LIKE 通配语义被注入(防前缀扫描越权命中)
|
|
334
|
+
expect(tablePrefixPattern("a!b%c_d")).toBe("a!!b!%c!_d_%");
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("P4-1 多方言卸载矩阵(续):collectRetainedTables 经 db.dialect 走 mysql/pg 信息_schema 路径", () => {
|
|
339
|
+
/** 注入指定方言的 db.query 桩:existing 返回全表清单、like 返回前缀命中,模拟 information_schema 行为 */
|
|
340
|
+
function stubDialect(dialect: "mysql" | "pg", existingNames: string[], likeNames: string[]): void {
|
|
341
|
+
const spec = catalogSpec(dialect);
|
|
342
|
+
(g.db as { dialect: () => string, query: (sql: string, p?: unknown[]) => unknown }).dialect = () => dialect;
|
|
343
|
+
(g.db as { query: (sql: string, p?: unknown[]) => unknown }).query = (sql: string) => {
|
|
344
|
+
if (sql === spec.existing)
|
|
345
|
+
return existingNames.map(name => ({ name }));
|
|
346
|
+
if (sql.includes("AND table_name LIKE"))
|
|
347
|
+
return likeNames.map(name => ({ name }));
|
|
348
|
+
return [];
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
it("mysql 方言:manifest 声明 + 前缀兜底扫描均经 information_schema,返回实际存在的业务表", async () => {
|
|
353
|
+
await seedInstalledWithTables();
|
|
354
|
+
stubDialect("mysql", ["demo_solution_notes", "demo_solution_tags", "demo_solution_orphan"], ["demo_solution_orphan"]);
|
|
355
|
+
const retained = await collectRetainedTables("demo-solution", join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml"));
|
|
356
|
+
expect(retained).toContain("demo_solution_notes");
|
|
357
|
+
expect(retained).toContain("demo_solution_tags");
|
|
358
|
+
expect(retained).toContain("demo_solution_orphan");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("pg 方言:'public' schema 表达式路径一致,返回业务表(与 mysql 口径相同)", async () => {
|
|
362
|
+
await seedInstalledWithTables();
|
|
363
|
+
stubDialect("pg", ["demo_solution_notes", "demo_solution_tags", "demo_solution_orphan"], ["demo_solution_orphan"]);
|
|
364
|
+
const retained = await collectRetainedTables("demo-solution", join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml"));
|
|
365
|
+
expect(retained).toContain("demo_solution_notes");
|
|
366
|
+
expect(retained).toContain("demo_solution_tags");
|
|
367
|
+
expect(retained).toContain("demo_solution_orphan");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("db.dialect 缺省回落 sqlite(不抛、不误导),与既有 sqlite 全量路径一致", async () => {
|
|
371
|
+
await seedInstalledWithTables();
|
|
372
|
+
// 还原为缺省(不提供 dialect 函数)→ currentDialect 回落 "sqlite"
|
|
373
|
+
const spec = catalogSpec("sqlite");
|
|
374
|
+
(g.db as { dialect?: () => string }).dialect = undefined;
|
|
375
|
+
(g.db as { query: (sql: string, p?: unknown[]) => unknown }).query = (sql: string) => {
|
|
376
|
+
if (sql === spec.existing)
|
|
377
|
+
return [{ name: "demo_solution_notes" }, { name: "demo_solution_tags" }];
|
|
378
|
+
if (sql.includes("AND name LIKE"))
|
|
379
|
+
return [{ name: "demo_solution_notes" }];
|
|
380
|
+
return [];
|
|
381
|
+
};
|
|
382
|
+
const retained = await collectRetainedTables("demo-solution", join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml"));
|
|
383
|
+
expect(retained).toContain("demo_solution_notes");
|
|
384
|
+
expect(retained).toContain("demo_solution_tags");
|
|
385
|
+
});
|
|
386
|
+
});
|