@oj-module/cli 0.2.66 → 0.2.67
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/bin/.oj-version +1 -0
- package/bin/oj +4 -0
- package/devkit/README.md +15 -7
- package/devkit/SKILL.md +30 -1
- package/devkit/framework-dev.md +10 -4
- package/devkit/manual.md +38 -12
- package/devkit/publish-walkthrough.md +110 -37
- package/devkit/wecom-internal-app.md +7 -2
- package/devkit/wecom-multi-corp-links.md +163 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
- package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.2dgstpjsu0.js} +3449 -3449
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/publish-keygen.test.ts +157 -0
- package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
- package/src/__tests__/templates-schema-columns.test.ts +15 -2
- package/src/__tests__/templates-tenant-scope.test.ts +6 -2
- package/src/bundle.ts +8 -3
- package/src/cli.ts +17 -5
- package/src/dev.ts +15 -10
- package/src/install.ts +11 -4
- package/src/keygen.ts +54 -0
- package/src/lifecycle/__tests__/bundle.test.ts +21 -3
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
- package/src/lifecycle/__tests__/smoke.test.ts +2 -2
- package/src/manifest-contract.ts +91 -8
- package/src/market/bootstrap.ts +26 -2
- package/src/market/command.ts +1 -0
- package/src/market/publish.ts +5 -1
- package/src/market/walkthrough.ts +24 -8
- package/src/site.ts +2 -0
- package/src/usage.ts +8 -4
- package/src/vendor.ts +5 -1
- package/templates/config.yaml +4 -2
- package/templates/init.include.yaml +8 -3
- package/templates/openapi.json +460 -1
- package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +71 -22
- package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
- package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
- package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
- package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
- package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
- package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
- package/templates/src/app/api/_platform/manifest.yaml +1 -0
- package/templates/src/app/api/_platform/market/api.ts +235 -10
- package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
- package/templates/src/app/api/_platform/schema.yaml +18 -0
- package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
- package/templates/src/app/api/_platform/seed.pg.sql +10 -0
- package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
- package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
- package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
- package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
- package/templates/src/app/api/home/seed.mysql.sql +71 -0
- package/templates/src/app/api/home/seed.pg.sql +71 -0
- package/templates/src/app/api/notification/_shared/notify.ts +6 -6
- package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
- package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
- package/templates/src/app/api/notification/seed.mysql.sql +8 -0
- package/templates/src/app/api/notification/seed.pg.sql +11 -0
- package/templates/src/app/api/publish/_shared/identity.ts +69 -0
- package/templates/src/app/api/publish/api.ts +349 -0
- package/templates/src/app/api/publish/manifest.yaml +8 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
- package/templates/src/app/api/publish/schema.yaml +33 -0
- package/templates/src/app/api/system/login-methods/api.ts +2 -1
- package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
- package/templates/src/app/web/_platform/__client/index.ts +25 -1
- package/templates/src/app/web/entry.ts +9 -5
- package/templates/src/app/web/home/routes.ts +5 -0
- package/templates/src/app/web/login/locales/en-US.json +7 -1
- package/templates/src/app/web/login/locales/zh-CN.json +7 -1
- package/templates/src/app/web/login/pages/login.tsx +79 -86
- package/templates/src/app/web/login/routes.ts +54 -1
- package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
- package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
- package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
- package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
- package/templates/src/app/web/system/locales/en-US.json +1 -8
- package/templates/src/app/web/system/locales/zh-CN.json +1 -8
- package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
- package/templates/src/app/web/system/routes.ts +0 -6
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
- package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
- package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
- package/templates/src/oj-market/web/api/client.ts +3 -0
- package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
- package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
- package/templates/src/oj-market/web/entry.ts +31 -2
- package/templates/src/oj-market/web/locales/en-US.json +5 -1
- package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
- package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
- package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
- package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
- package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
- package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
- package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
- package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
- package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
- package/templates/src/oj-market/web/publish/routes.ts +41 -0
- package/templates/src/oj-market/web/types.ts +30 -0
- package/templates/tests/lifecycle/compensate.spec.ts +207 -0
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
- package/templates/tests/lifecycle/installer.spec.ts +28 -9
- package/templates/tests/lifecycle/market-api.spec.ts +164 -12
- package/templates/tests/lifecycle/preflight.spec.ts +68 -0
- package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -32
- package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
- package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
- package/templates/tests/publish-credential.spec.ts +354 -0
- package/templates/tests/publish-identity.spec.ts +44 -0
- package/templates/tests/wecom-client-timeout.spec.ts +33 -3
- package/templates/tests/wecom-landing.spec.ts +63 -1
- package/templates/tsconfig.paths.json +1 -0
- package/templates/web.config.ts +6 -4
- package/vendor/host-versions.json +3 -3
- package/templates/seed.mysql.sql +0 -6
- package/templates/seed.pg.sql +0 -6
- package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
- package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
- package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
- package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
- package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
- package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
|
@@ -305,3 +305,71 @@ describe("preflight · C2 夹具(双端唯一真源)", () => {
|
|
|
305
305
|
}
|
|
306
306
|
});
|
|
307
307
|
});
|
|
308
|
+
|
|
309
|
+
describe("p0 §3.9 宿主能力门禁(写锁前失败,零痕迹)", () => {
|
|
310
|
+
it("老 oj(capabilities 不含所需)+ 新模板声明 requiresCapabilities → 写锁前 failed(host_capability),retryable=false", async () => {
|
|
311
|
+
const m = structuredClone(VALID);
|
|
312
|
+
m.requiresCapabilities = ["reload.modules_in_key", "anon.per_generation"];
|
|
313
|
+
const r = await preflight(ctx({ manifest: m, capabilities: [] }));
|
|
314
|
+
expect(r.ok).toBe(false);
|
|
315
|
+
expect(r.code).toBe("host_capability");
|
|
316
|
+
expect(r.retryable).toBe(false);
|
|
317
|
+
expect(r.message).toContain("reload.modules_in_key");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("宿主 capability 未上报(undefined)+ 模板声明 → fail-closed 同拒", async () => {
|
|
321
|
+
const m = structuredClone(VALID);
|
|
322
|
+
m.requiresCapabilities = ["reload.api"];
|
|
323
|
+
const r = await preflight(ctx({ manifest: m })); // 无 capabilities
|
|
324
|
+
expect(r.ok).toBe(false);
|
|
325
|
+
expect(r.code).toBe("host_capability");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("宿主具备所需能力 → 放行", async () => {
|
|
329
|
+
const m = structuredClone(VALID);
|
|
330
|
+
m.requiresCapabilities = ["reload.api", "reload.modules_in_key"];
|
|
331
|
+
const r = await preflight(ctx({ manifest: m, capabilities: ["reload.api", "reload.modules_in_key", "anon.per_generation"] }));
|
|
332
|
+
expect(r.ok).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("既有模板不声明 requiresCapabilities → 门禁恒放行(不依赖上游 US-1 即先落地)", async () => {
|
|
336
|
+
const r = await preflight(ctx({ capabilities: [] }));
|
|
337
|
+
expect(r.ok).toBe(true);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("p0 §3.2 质辩1 / E7·E8 匿名保留段与残留检测(写锁前拒装)", () => {
|
|
342
|
+
it("e7 方案声明保留段匿名条目 → 拒装(C2-26)", async () => {
|
|
343
|
+
const m = structuredClone(VALID);
|
|
344
|
+
m.api.anonymous = ["_platform/health"];
|
|
345
|
+
const r = await preflight(ctx({ manifest: m }));
|
|
346
|
+
expect(r.ok).toBe(false);
|
|
347
|
+
expect(r.message).toContain("C2-26");
|
|
348
|
+
expect(r.message).toContain("_platform");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("e8 config 存在未被本 manifest 声明的同 prefix 匿名残留 → 拒装(anonymous_residual)", async () => {
|
|
352
|
+
const m = structuredClone(VALID);
|
|
353
|
+
m.api.anonymous = ["demo-solution/ping"];
|
|
354
|
+
const r = await preflight(ctx({ manifest: m, existingAnonymousPaths: ["/demo-solution/legacy"] }));
|
|
355
|
+
expect(r.ok).toBe(false);
|
|
356
|
+
expect(r.code).toBe("anonymous_residual");
|
|
357
|
+
expect(r.message).toContain("/demo-solution/legacy");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("e8 config 残留条目恰为本 manifest 已声明 → 不误伤放行", async () => {
|
|
361
|
+
const m = structuredClone(VALID);
|
|
362
|
+
m.api.anonymous = ["demo-solution/ping"];
|
|
363
|
+
const r = await preflight(ctx({ manifest: m, existingAnonymousPaths: ["/demo-solution/ping"] }));
|
|
364
|
+
expect(r.ok).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("e8 config 残留指向保留段 → 不误伤放行(保留段属宿主内建,方案无法注入,非方案残留)", async () => {
|
|
368
|
+
const m = structuredClone(VALID);
|
|
369
|
+
m.api.anonymous = ["demo-solution/ping"];
|
|
370
|
+
// /_platform/health 是宿主内建匿名端点(如 /auth/login),方案 C2-26 已挡住声明保留段匿名,
|
|
371
|
+
// 故 config 里的保留段条目绝不会是方案残留——纳入比对只会误伤宿主内建,须放行。
|
|
372
|
+
const r = await preflight(ctx({ manifest: m, existingAnonymousPaths: ["/_platform/health", "/auth/login"] }));
|
|
373
|
+
expect(r.ok).toBe(true);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
// BDD:solution 角色 title 落库时同步翻译(R1–R6,计划 docs/plans/20261103-0948-role-title-translate)。
|
|
2
2
|
// title = 解析文本(站点默认语言)、title_key = 原文(UI 实时翻译锚);解析失败回退裸 key,
|
|
3
|
-
// 绝不失败安装(R1);升级元数据覆盖 = 重解析(R3
|
|
4
|
-
// builtin 不动(R2/AC4);sys_permission 不变(R5);name/code/绑定不变(R6)。
|
|
3
|
+
// 绝不失败安装(R1);升级元数据覆盖 = 重解析(R3);title_key 列由 0001 基线建表即带(R2/AC4);sys_permission 不变(R5);name/code/绑定不变(R6)。
|
|
5
4
|
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写(register.spec 同约定) */
|
|
6
|
-
import { createRequire } from "node:module";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
5
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
9
6
|
import { registerSolution, resolveDeclTitles, siteLanguage } from "../../src/app/api/_platform/_lifecycle/steps/register";
|
|
10
7
|
import { installDb } from "../fixture";
|
|
11
8
|
|
|
12
|
-
const req = createRequire(import.meta.url);
|
|
13
|
-
const { DatabaseSync } = req("node:sqlite");
|
|
14
|
-
const { readFileSync } = req("node:fs");
|
|
15
|
-
const { dirname, join } = req("node:path");
|
|
16
|
-
|
|
17
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
9
|
const enc = new TextEncoder();
|
|
19
10
|
|
|
20
11
|
let dbo: ReturnType<typeof installDb>;
|
|
@@ -142,30 +133,9 @@ describe("R1–R3 落库(registerSolution 消费解析后的 decl)", () => {
|
|
|
142
133
|
});
|
|
143
134
|
});
|
|
144
135
|
|
|
145
|
-
describe("R2
|
|
136
|
+
describe("R2 sys_role.title_key 列(基线建表即带)", () => {
|
|
146
137
|
it("AC4 夹具真实迁移后 sys_role 带 title_key 列(列进库 ↔ schema.yaml 一致守卫的库侧半边)", () => {
|
|
147
138
|
const cols = (dbo.prepare("PRAGMA table_info(sys_role)").all() as Array<{ name: string }>).map(c => c.name);
|
|
148
139
|
expect(cols).toContain("title_key");
|
|
149
140
|
});
|
|
150
|
-
|
|
151
|
-
it("AC4 存量 solution 角色按 key 形状回填,builtin 角色不动、title 原文不改", () => {
|
|
152
|
-
const raw = new DatabaseSync(":memory:");
|
|
153
|
-
raw.exec(`
|
|
154
|
-
CREATE TABLE sys_role (
|
|
155
|
-
name TEXT PRIMARY KEY, module TEXT NOT NULL, title TEXT, description TEXT,
|
|
156
|
-
builtin INTEGER NOT NULL DEFAULT 0, deprecated INTEGER NOT NULL DEFAULT 0
|
|
157
|
-
);
|
|
158
|
-
INSERT INTO sys_role (name, module, title, builtin) VALUES
|
|
159
|
-
('pm.sales', 'pm', 'pm:role.sales', 0),
|
|
160
|
-
('wiki.admin', 'wiki', 'wiki:role.admin', 0),
|
|
161
|
-
('admin', 'framework', '系统管理员', 1);
|
|
162
|
-
`);
|
|
163
|
-
raw.exec(readFileSync(join(HERE, "../../src/app/api/_platform/migrations/0018__sys_role_title_key.sql"), "utf8"));
|
|
164
|
-
const rows = raw.prepare("SELECT name, title, title_key FROM sys_role ORDER BY name").all() as Array<{ name: string, title: string, title_key: string | null }>;
|
|
165
|
-
expect(rows).toEqual([
|
|
166
|
-
{ name: "admin", title: "系统管理员", title_key: null },
|
|
167
|
-
{ name: "pm.sales", title: "pm:role.sales", title_key: "pm:role.sales" },
|
|
168
|
-
{ name: "wiki.admin", title: "wiki:role.admin", title_key: "wiki:role.admin" },
|
|
169
|
-
]);
|
|
170
|
-
});
|
|
171
141
|
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import * as fsp from "node:fs/promises";
|
|
6
6
|
import { describe, expect, it } from "vitest";
|
|
7
7
|
|
|
8
|
-
import { appendAnonymousPaths,
|
|
8
|
+
import { appendAnonymousPaths, ensureReloadApi, hasAnchor, insertAnchor, removeAnonymousPaths } from "../../src/app/api/_platform/_lifecycle/text-edit";
|
|
9
9
|
|
|
10
10
|
describe("insertAnchor(R18)", () => {
|
|
11
11
|
it("带注释的 config.yaml 插入后注释/缩进/空行/键序逐字保留", () => {
|
|
@@ -105,32 +105,6 @@ describe("ensureReloadApi(P1-10,D3 一次性注入集收口:S1-1 定稿无
|
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
describe("bumpInstallSeq(B-11 reload 触发面:vars 安装序号自增,PRD E5 实测 vars 改动可热生效)", () => {
|
|
109
|
-
it("0 → 1 自增,其余行(注释/缩进/键序)逐字保留", () => {
|
|
110
|
-
const content = "mounts: []\nvars:\n ojm_install_seq: 0\n# 尾注释保留";
|
|
111
|
-
const r = bumpInstallSeq(content);
|
|
112
|
-
expect(r.seq).toBe(1);
|
|
113
|
-
expect(r.content).toBe("mounts: []\nvars:\n ojm_install_seq: 1\n# 尾注释保留");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("多次调用累加(N → N+1),行内尾注释原样保留", () => {
|
|
117
|
-
const content = "vars:\n ojm_install_seq: 41 # 安装序号";
|
|
118
|
-
const r1 = bumpInstallSeq(content);
|
|
119
|
-
expect(r1.seq).toBe(42);
|
|
120
|
-
expect(r1.content).toBe("vars:\n ojm_install_seq: 42 # 安装序号");
|
|
121
|
-
const r2 = bumpInstallSeq(r1.content);
|
|
122
|
-
expect(r2.seq).toBe(43);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("键缺失 → 可读报错(用户删行 = 显式退出热更触发,安装器不猜位置)", () => {
|
|
126
|
-
expect(() => bumpInstallSeq("vars:\n other: 1")).toThrowError(/ojm_install_seq/);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("值非数字(行存在但形状不符)→ 视同缺失,可读报错不猜", () => {
|
|
130
|
-
expect(() => bumpInstallSeq("vars:\n ojm_install_seq: abc")).toThrowError(/ojm_install_seq/);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
108
|
describe("模板 config.yaml 随发锚点(P1-16 接线②:ensureReloadApi 键缺失插入路径的闭环依据)", () => {
|
|
135
109
|
it("模板 config.yaml 带整行 # ojm:reload 锚点;键在 → ensureReloadApi no-op", async () => {
|
|
136
110
|
const cfg = await fsp.readFile(new URL("../../config.yaml", import.meta.url), "utf8");
|
|
@@ -175,3 +149,47 @@ describe("p2-15③ C2-26 anonymous 运行时接线(appendAnonymousPaths:conf
|
|
|
175
149
|
expect(() => appendAnonymousPaths("server:\n reload_api: true\n", ["x/y"])).toThrow(/anonymous_paths/);
|
|
176
150
|
});
|
|
177
151
|
});
|
|
152
|
+
|
|
153
|
+
describe("removeAnonymousPaths(P0 §3.5 补偿清单:appendAnonymousPaths 逆操作)", () => {
|
|
154
|
+
const BASE = "auth:\n anonymous_paths:\n - /auth/login\n # 行内注释保留\n - /system/login-methods\n";
|
|
155
|
+
|
|
156
|
+
it("追加后移除 = 还原到追加前形态(注释/缩进/既有项逐字保留)", () => {
|
|
157
|
+
const added = appendAnonymousPaths(BASE, ["demo-solution/ping", "demo-solution/health"]);
|
|
158
|
+
const r = removeAnonymousPaths(added.content, ["demo-solution/ping", "demo-solution/health"]);
|
|
159
|
+
expect(r.removed).toEqual(["/demo-solution/ping", "/demo-solution/health"]);
|
|
160
|
+
expect(r.content).toBe(BASE);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("只移除指定项,其余匿名条目不动", () => {
|
|
164
|
+
const added = appendAnonymousPaths(BASE, ["demo-solution/ping", "demo-solution/health"]);
|
|
165
|
+
const r = removeAnonymousPaths(added.content, ["demo-solution/ping"]);
|
|
166
|
+
expect(r.removed).toEqual(["/demo-solution/ping"]);
|
|
167
|
+
expect(r.content).toContain("- /demo-solution/health");
|
|
168
|
+
expect(r.content).toContain("- /auth/login");
|
|
169
|
+
expect(r.content).toContain("- /system/login-methods");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("幂等:未含的路径不在列表内 → removed=[] 且内容不变", () => {
|
|
173
|
+
const added = appendAnonymousPaths(BASE, ["demo-solution/ping"]);
|
|
174
|
+
const r = removeAnonymousPaths(added.content, ["other/not-present"]);
|
|
175
|
+
expect(r.removed).toEqual([]);
|
|
176
|
+
expect(r.content).toBe(added.content);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("连续两次移除同一批路径,第二次为 no-op(防重复副作用)", () => {
|
|
180
|
+
const added = appendAnonymousPaths(BASE, ["demo-solution/ping"]);
|
|
181
|
+
const once = removeAnonymousPaths(added.content, ["demo-solution/ping"]);
|
|
182
|
+
const twice = removeAnonymousPaths(once.content, ["demo-solution/ping"]);
|
|
183
|
+
expect(twice.removed).toEqual([]);
|
|
184
|
+
expect(twice.content).toBe(once.content);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("键缺失 = 没东西可删,no-op(不报错)", () => {
|
|
188
|
+
const r = removeAnonymousPaths("server:\n reload_api: true\n", ["x/y"]);
|
|
189
|
+
expect(r.removed).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("空路径列表 = no-op", () => {
|
|
193
|
+
expect(removeAnonymousPaths(BASE, []).removed).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -196,6 +196,42 @@ describe("POST uninstall 卸载编排(superadmin,R9)", () => {
|
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
describe("Phase 4:卸载 reload 失败回滚(P0 §3.4 第5步,依赖 US-1 manifests 摘要驱动的装配面,本仓先落编排)", () => {
|
|
200
|
+
it("卸载摘锁后 reload 失败 → 锁写回(模块仍挂载) + ok:false + 版本目录不删(fail-closed 一致回滚)", async () => {
|
|
201
|
+
await seedInstalled();
|
|
202
|
+
const api = createMarketApi(makeEnv({
|
|
203
|
+
reload: {
|
|
204
|
+
reloadConfig: async () => {
|
|
205
|
+
throw new Error("reload boom");
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
}));
|
|
209
|
+
g.http = { user: { id: 1 }, body: { name: "demo-solution" } };
|
|
210
|
+
await api.uninstall.post();
|
|
211
|
+
// 锁写回:模块仍挂载(other 保留 + demo-solution 恢复)
|
|
212
|
+
expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ "demo-solution": "0.1.0", "other": "1.0.0" });
|
|
213
|
+
// 版本目录未删(先失可达性再失文件;reload 失败即停,不删文件)
|
|
214
|
+
await expect(fsp.readdir(join(sharedRoot, "demo-solution-0.1.0"))).resolves.toBeDefined();
|
|
215
|
+
// sidecar 未删(元数据一致回滚,非半卸)
|
|
216
|
+
const state = await createStateStore(statePath);
|
|
217
|
+
expect(state.read().solutions["demo-solution"]).toBeDefined();
|
|
218
|
+
expect(last?.status).toBe(200);
|
|
219
|
+
expect(last?.data).toMatchObject({ ok: false });
|
|
220
|
+
expect(String((last?.data as { message?: string } | undefined)?.message)).toContain("reload 失败");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("卸载 reload 成功 → 版本目录正常移除、锁摘除(既有行为不破坏)", async () => {
|
|
224
|
+
await seedInstalled();
|
|
225
|
+
const api = createMarketApi(makeEnv({ reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["-demo-solution@0.1.0"], restart_pending: [] }) } }));
|
|
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: true });
|
|
230
|
+
expect(await readLock(join(sharedRoot, "manifests.yaml"))).toEqual({ other: "1.0.0" });
|
|
231
|
+
await expect(fsp.readdir(join(sharedRoot, "demo-solution-0.1.0"))).rejects.toBeTruthy();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
199
235
|
describe("P3-3 卸载级联单一事务(fail-closed:中段失败整段回滚、锁不摘、无半残)", () => {
|
|
200
236
|
it("权限级联中段失败 → 事务回滚(route/角色/权限零残)、锁/sidecar/modules/版本目录原样保留", async () => {
|
|
201
237
|
await seedInstalled();
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// BDD:发布凭证端点族(申请 / 列表 / 撤销 / 兑换)。
|
|
2
|
+
//
|
|
3
|
+
// 这一层要钉的是**跨请求才看得见的语义**,纯函数测不到:
|
|
4
|
+
// · 一次性:兑换成功后同票再兑必拒(并发双花只有一个赢家)
|
|
5
|
+
// · 时效:过期即拒,且拒绝发生在「已经拿到身份」之前
|
|
6
|
+
// · 隔离:普通用户看不到别人的凭证,超管看得到;归属租户来自 users 行而非请求头
|
|
7
|
+
// · 不留痕:明文只在申请响应里出现一次,库里只有 sha256
|
|
8
|
+
/* eslint-disable test/prefer-lowercase-title -- 用例标题带端点路径,保留原样更易检索 */
|
|
9
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
10
|
+
import { invalidateUser } from "../src/app/api/_platform/_shared/service/infra/access";
|
|
11
|
+
import { CREDENTIAL_TTL_SECS } from "../src/app/api/publish/_shared/identity";
|
|
12
|
+
import { createPublishApi } from "../src/app/api/publish/api";
|
|
13
|
+
import { installDb } from "./fixture";
|
|
14
|
+
|
|
15
|
+
const g = globalThis as any;
|
|
16
|
+
let dbo: ReturnType<typeof installDb>;
|
|
17
|
+
|
|
18
|
+
interface Captured { status: number | null, body: any }
|
|
19
|
+
let out: Captured;
|
|
20
|
+
|
|
21
|
+
/** 固定令牌:必须是 64 位 hex,才过得了兑换端点的形状闸门 */
|
|
22
|
+
const TOKEN_1 = "a".repeat(64);
|
|
23
|
+
const TOKEN_2 = "b".repeat(64);
|
|
24
|
+
|
|
25
|
+
let clock: number;
|
|
26
|
+
|
|
27
|
+
/** 假摘要:短、确定性、且**不含原文**(否则「明文不落库」的断言会被夹具自己污染) */
|
|
28
|
+
function digest(input: string): string {
|
|
29
|
+
let acc = 7;
|
|
30
|
+
for (const ch of input)
|
|
31
|
+
acc = (acc * 33 + ch.charCodeAt(0)) >>> 0;
|
|
32
|
+
return `h${acc.toString(16)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function installJson(): void {
|
|
36
|
+
out = { status: null, body: null };
|
|
37
|
+
g.json = {
|
|
38
|
+
ok: (data?: unknown) => {
|
|
39
|
+
out.status = 200;
|
|
40
|
+
out.body = data;
|
|
41
|
+
},
|
|
42
|
+
fail: (code: number, msg: string, extra?: unknown) => {
|
|
43
|
+
out.status = code;
|
|
44
|
+
out.body = { code, msg, extra };
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function api(tokens: string[] = [TOKEN_1, TOKEN_2]) {
|
|
50
|
+
const queue = [...tokens];
|
|
51
|
+
return createPublishApi({
|
|
52
|
+
now: () => clock,
|
|
53
|
+
randomHex: () => queue.shift() ?? "c".repeat(64),
|
|
54
|
+
sha256Hex: digest,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mkUser(username: string, opts: { tenant?: string, displayName?: string, superadmin?: boolean } = {}): number {
|
|
59
|
+
const tenant = opts.tenant ?? "default";
|
|
60
|
+
dbo.prepare("INSERT INTO users (username, password_hash, display_name, tenant_id) VALUES (?, 'x', ?, ?)")
|
|
61
|
+
.run(username, opts.displayName ?? null, tenant);
|
|
62
|
+
const uid = (dbo.prepare("SELECT id FROM users WHERE username = ? AND tenant_id = ?").get(username, tenant) as { id: number }).id;
|
|
63
|
+
if (opts.superadmin)
|
|
64
|
+
dbo.prepare("INSERT INTO sys_user_role (user_id, role_name, tenant_id) VALUES (?, 'superadmin', ?)").run(uid, tenant);
|
|
65
|
+
return uid;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 以某个登录用户身份发请求 */
|
|
69
|
+
function as(uid: number, extra: Record<string, unknown> = {}): void {
|
|
70
|
+
g.http = { user: { id: uid }, tenantId: "default", headers: { "x-real-ip": "10.0.0.1" }, ...extra };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rowOf(token: string): Record<string, unknown> | undefined {
|
|
74
|
+
return dbo.prepare("SELECT * FROM publish_credential WHERE token_hash = ?").get(digest(token)) as Record<string, unknown> | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
beforeAll(() => {
|
|
78
|
+
dbo = installDb();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
g.http = {};
|
|
83
|
+
clock = 1_700_000_000;
|
|
84
|
+
dbo.exec("DELETE FROM publish_credential");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("POST /api/publish/credential(申请)", () => {
|
|
88
|
+
it("given 登录用户填合法产品名 When 申请 Then 返回 64 位明文、身份已定,库里只有 hash", async () => {
|
|
89
|
+
installJson();
|
|
90
|
+
clock = 1_700_000_000;
|
|
91
|
+
const uid = mkUser("p_apply", { displayName: "张三" });
|
|
92
|
+
as(uid, { body: { product: "demo-solution" } });
|
|
93
|
+
|
|
94
|
+
await api().credential.post();
|
|
95
|
+
|
|
96
|
+
expect(out.status).toBe(200);
|
|
97
|
+
expect(out.body.token).toBe(TOKEN_1);
|
|
98
|
+
expect(out.body.key_id).toBe("demo-solution.p-apply");
|
|
99
|
+
expect(out.body.expires_at).toBe(clock + CREDENTIAL_TTL_SECS);
|
|
100
|
+
expect(out.body.token_hint).toBe(TOKEN_1.slice(0, 8));
|
|
101
|
+
|
|
102
|
+
const row = rowOf(TOKEN_1)!;
|
|
103
|
+
expect(row.token_hash).toBe(digest(TOKEN_1));
|
|
104
|
+
expect(row.display_name).toBe("张三");
|
|
105
|
+
expect(row.owner_tenant).toBe("default");
|
|
106
|
+
// 明文绝不落库
|
|
107
|
+
expect(JSON.stringify(row)).not.toContain(TOKEN_1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("given 产品名是保留名 When 申请 Then 400 且不产生记录", async () => {
|
|
111
|
+
installJson();
|
|
112
|
+
const uid = mkUser("p_bad");
|
|
113
|
+
as(uid, { body: { product: "market" } });
|
|
114
|
+
|
|
115
|
+
await api().credential.post();
|
|
116
|
+
|
|
117
|
+
expect(out.status).toBe(400);
|
|
118
|
+
expect(out.body.msg).toContain("保留名");
|
|
119
|
+
expect(dbo.prepare("SELECT count(*) AS c FROM publish_credential").get()).toEqual({ c: 0 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("given 同一产品名已被他人占用 When 另一用户申请 Then 409(同一人重复申请放行)", async () => {
|
|
123
|
+
installJson();
|
|
124
|
+
clock = 1_700_000_000;
|
|
125
|
+
|
|
126
|
+
// 同名账号在不同租户:唯一键是 (tenant_id, username),故可共存
|
|
127
|
+
const a = mkUser("p_dup", { tenant: "default" });
|
|
128
|
+
const b = mkUser("p_dup", { tenant: "t2" });
|
|
129
|
+
|
|
130
|
+
as(a, { body: { product: "demo-solution" } });
|
|
131
|
+
await api([TOKEN_1]).credential.post();
|
|
132
|
+
expect(out.status).toBe(200);
|
|
133
|
+
|
|
134
|
+
as(b, { tenantId: "t2", body: { product: "demo-solution" } });
|
|
135
|
+
await api([TOKEN_2]).credential.post();
|
|
136
|
+
expect(out.status).toBe(409);
|
|
137
|
+
expect(out.body.msg).toContain("已被其他用户占用");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("POST /api/publish/credential/exchange(兑换)", () => {
|
|
142
|
+
it("given 有效凭证 When 兑换 Then 返回申请时定下的 key_id 且该票作废", async () => {
|
|
143
|
+
installJson();
|
|
144
|
+
clock = 1_700_000_000;
|
|
145
|
+
const uid = mkUser("p_ex1");
|
|
146
|
+
as(uid, { body: { product: "demo-solution" } });
|
|
147
|
+
const p = api([TOKEN_1]);
|
|
148
|
+
await p.credential.post();
|
|
149
|
+
const issued = out.body;
|
|
150
|
+
|
|
151
|
+
// 兑换是匿名的:不带 user
|
|
152
|
+
g.http = { headers: { "x-real-ip": "10.0.0.9" }, body: { token: issued.token } };
|
|
153
|
+
await p.credential.exchange.post();
|
|
154
|
+
|
|
155
|
+
expect(out.status).toBe(200);
|
|
156
|
+
expect(out.body).toMatchObject({ key_id: issued.key_id, product: "demo-solution", username: "p_ex1" });
|
|
157
|
+
|
|
158
|
+
const row = rowOf(TOKEN_1)!;
|
|
159
|
+
expect(Number(row.used)).toBe(1);
|
|
160
|
+
expect(Number(row.used_at)).toBe(clock);
|
|
161
|
+
|
|
162
|
+
// 再兑:同票必拒
|
|
163
|
+
g.http = { headers: { "x-real-ip": "10.0.0.9" }, body: { token: issued.token } };
|
|
164
|
+
await p.credential.exchange.post();
|
|
165
|
+
expect(out.status).toBe(400);
|
|
166
|
+
expect(out.body.msg).toContain("发布管理");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("given 凭证已过期 When 兑换 Then 400(过期判定在签发身份之前)", async () => {
|
|
170
|
+
installJson();
|
|
171
|
+
clock = 1_700_000_000;
|
|
172
|
+
const uid = mkUser("p_ex2");
|
|
173
|
+
as(uid, { body: { product: "demo-solution" } });
|
|
174
|
+
const p = api([TOKEN_1]);
|
|
175
|
+
await p.credential.post();
|
|
176
|
+
|
|
177
|
+
clock += CREDENTIAL_TTL_SECS + 1;
|
|
178
|
+
g.http = { headers: { "x-real-ip": "10.0.0.9" }, body: { token: TOKEN_1 } };
|
|
179
|
+
await p.credential.exchange.post();
|
|
180
|
+
|
|
181
|
+
expect(out.status).toBe(400);
|
|
182
|
+
expect(Number(rowOf(TOKEN_1)!.used)).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("given 凭证已撤销 When 兑换 Then 400", async () => {
|
|
186
|
+
installJson();
|
|
187
|
+
clock = 1_700_000_000;
|
|
188
|
+
const uid = mkUser("p_ex3");
|
|
189
|
+
as(uid, { body: { product: "demo-solution" } });
|
|
190
|
+
const p = api([TOKEN_1]);
|
|
191
|
+
await p.credential.post();
|
|
192
|
+
const id = Number(rowOf(TOKEN_1)!.id);
|
|
193
|
+
|
|
194
|
+
as(uid, { body: { id } });
|
|
195
|
+
await p.credential.revoke.post();
|
|
196
|
+
expect(out.status).toBe(200);
|
|
197
|
+
|
|
198
|
+
g.http = { headers: { "x-real-ip": "10.0.0.9" }, body: { token: TOKEN_1 } };
|
|
199
|
+
await p.credential.exchange.post();
|
|
200
|
+
expect(out.status).toBe(400);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("given 同一张票连续兑换 3 次 When 依次到达 Then 只有第一次成功,used_at 停在首次时间", async () => {
|
|
204
|
+
installJson();
|
|
205
|
+
clock = 1_700_000_000;
|
|
206
|
+
const uid = mkUser("p_ex4");
|
|
207
|
+
as(uid, { body: { product: "demo-solution" } });
|
|
208
|
+
const p = api([TOKEN_1]);
|
|
209
|
+
await p.credential.post();
|
|
210
|
+
|
|
211
|
+
// 原子性来自那一条 `update ... where used = 0`:并发双花只可能有一个 changes=1。
|
|
212
|
+
// Node 侧 sqlite 是同步的,真并发无法复现,故钉「重复到达只有一次生效」这一可观测面。
|
|
213
|
+
const firstAt = clock;
|
|
214
|
+
const statuses: number[] = [];
|
|
215
|
+
for (let i = 0; i < 3; i++) {
|
|
216
|
+
g.http = { headers: { "x-real-ip": `10.0.0.${i + 1}` }, body: { token: TOKEN_1 } };
|
|
217
|
+
await p.credential.exchange.post();
|
|
218
|
+
statuses.push(out.status!);
|
|
219
|
+
clock += 5;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
expect(statuses).toEqual([200, 400, 400]);
|
|
223
|
+
expect(Number(rowOf(TOKEN_1)!.used_at)).toBe(firstAt);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("given 令牌形状不对 When 兑换 Then 400 且不触库(不打日志也不落审计)", async () => {
|
|
227
|
+
installJson();
|
|
228
|
+
g.http = { headers: { "x-real-ip": "10.0.0.6" }, body: { token: "not-a-token" } };
|
|
229
|
+
await api().credential.exchange.post();
|
|
230
|
+
expect(out.status).toBe(400);
|
|
231
|
+
expect(out.body.msg).toContain("重新申请");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("GET /api/publish/credential(列表)", () => {
|
|
236
|
+
it("given 普通用户 When 查列表 Then 只看到自己的且响应不含 token/hash", async () => {
|
|
237
|
+
installJson();
|
|
238
|
+
clock = 1_700_000_000;
|
|
239
|
+
const a = mkUser("p_list_a");
|
|
240
|
+
const b = mkUser("p_list_b");
|
|
241
|
+
|
|
242
|
+
as(a, { body: { product: "demo-solution" } });
|
|
243
|
+
await api([TOKEN_1]).credential.post();
|
|
244
|
+
as(b, { body: { product: "other-solution" } });
|
|
245
|
+
await api([TOKEN_2]).credential.post();
|
|
246
|
+
|
|
247
|
+
as(a);
|
|
248
|
+
await api().credential.get();
|
|
249
|
+
|
|
250
|
+
expect(out.status).toBe(200);
|
|
251
|
+
expect(out.body.total).toBe(1);
|
|
252
|
+
expect(out.body.list[0]).toMatchObject({ product: "demo-solution", key_id: "demo-solution.p-list-a", status: "active" });
|
|
253
|
+
// 明文与哈希都不出现在列表里
|
|
254
|
+
const serialized = JSON.stringify(out.body);
|
|
255
|
+
expect(serialized).not.toContain(TOKEN_1);
|
|
256
|
+
expect(serialized).not.toContain("token_hash");
|
|
257
|
+
expect(out.body.list[0].owner_tenant).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("given 超管 When 查列表 Then 能看到全部并带归属租户", async () => {
|
|
261
|
+
installJson();
|
|
262
|
+
clock = 1_700_000_000;
|
|
263
|
+
const a = mkUser("p_list_c");
|
|
264
|
+
const boss = mkUser("p_boss", { superadmin: true });
|
|
265
|
+
|
|
266
|
+
as(a, { body: { product: "demo-solution" } });
|
|
267
|
+
await api([TOKEN_1]).credential.post();
|
|
268
|
+
|
|
269
|
+
as(boss);
|
|
270
|
+
await api().credential.get();
|
|
271
|
+
|
|
272
|
+
expect(out.status).toBe(200);
|
|
273
|
+
expect(out.body.total).toBe(1);
|
|
274
|
+
expect(out.body.list[0].owner_tenant).toBe("default");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("given 状态演进 When 兑换/撤销后查列表 Then 状态字段随之变化", async () => {
|
|
278
|
+
installJson();
|
|
279
|
+
clock = 1_700_000_000;
|
|
280
|
+
const uid = mkUser("p_list_d");
|
|
281
|
+
as(uid, { body: { product: "demo-solution" } });
|
|
282
|
+
const p = api([TOKEN_1]);
|
|
283
|
+
await p.credential.post();
|
|
284
|
+
|
|
285
|
+
g.http = { headers: { "x-real-ip": "10.0.0.5" }, body: { token: TOKEN_1 } };
|
|
286
|
+
await p.credential.exchange.post();
|
|
287
|
+
|
|
288
|
+
as(uid);
|
|
289
|
+
await p.credential.get();
|
|
290
|
+
expect(out.body.list[0].status).toBe("used");
|
|
291
|
+
expect(out.body.list[0].used_at).toBe(clock);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("POST /api/publish/credential/revoke(撤销)", () => {
|
|
296
|
+
it("given 别人的凭证 When 撤销 Then 404(与不存在同一句话,不泄露存在性)", async () => {
|
|
297
|
+
installJson();
|
|
298
|
+
clock = 1_700_000_000;
|
|
299
|
+
const owner = mkUser("p_rev_a");
|
|
300
|
+
const stranger = mkUser("p_rev_b");
|
|
301
|
+
|
|
302
|
+
as(owner, { body: { product: "demo-solution" } });
|
|
303
|
+
await api([TOKEN_1]).credential.post();
|
|
304
|
+
const id = Number(rowOf(TOKEN_1)!.id);
|
|
305
|
+
|
|
306
|
+
as(stranger, { body: { id } });
|
|
307
|
+
await api().credential.revoke.post();
|
|
308
|
+
|
|
309
|
+
expect(out.status).toBe(404);
|
|
310
|
+
expect(Number(rowOf(TOKEN_1)!.revoked)).toBe(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("given 已兑换的凭证 When 撤销 Then 400 并说明发布身份不能撤回", async () => {
|
|
314
|
+
installJson();
|
|
315
|
+
clock = 1_700_000_000;
|
|
316
|
+
const uid = mkUser("p_rev_c");
|
|
317
|
+
as(uid, { body: { product: "demo-solution" } });
|
|
318
|
+
const p = api([TOKEN_1]);
|
|
319
|
+
await p.credential.post();
|
|
320
|
+
const id = Number(rowOf(TOKEN_1)!.id);
|
|
321
|
+
|
|
322
|
+
g.http = { headers: { "x-real-ip": "10.0.0.4" }, body: { token: TOKEN_1 } };
|
|
323
|
+
await p.credential.exchange.post();
|
|
324
|
+
|
|
325
|
+
as(uid, { body: { id } });
|
|
326
|
+
await p.credential.revoke.post();
|
|
327
|
+
expect(out.status).toBe(400);
|
|
328
|
+
expect(out.body.msg).toContain("发布身份");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("门禁", () => {
|
|
333
|
+
it("given 未登录 When 访问列表/申请 Then 403", async () => {
|
|
334
|
+
installJson();
|
|
335
|
+
as(0);
|
|
336
|
+
g.http = {};
|
|
337
|
+
await api().credential.get();
|
|
338
|
+
expect(out.status).toBe(403);
|
|
339
|
+
|
|
340
|
+
installJson();
|
|
341
|
+
g.http = { body: { product: "demo-solution" } };
|
|
342
|
+
await api().credential.post();
|
|
343
|
+
expect(out.status).toBe(403);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("given 伪造租户头 When 访问列表 Then 403(sub 在当前租户查无此人)", async () => {
|
|
347
|
+
installJson();
|
|
348
|
+
const uid = mkUser("p_forge");
|
|
349
|
+
invalidateUser(uid);
|
|
350
|
+
g.http = { user: { id: uid }, tenantId: "other-tenant" };
|
|
351
|
+
await api().credential.get();
|
|
352
|
+
expect(out.status).toBe(403);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// BDD:发布身份推导规则(publish/_shared/identity.ts)。
|
|
2
|
+
// 这条规则同时决定 ~/.ojm/keys/<id>/ 的目录名、.sig 的 key_id 与市场卡片上的「发布者」,
|
|
3
|
+
// 一旦漂移就是「签出来的身份查不到凭证」这类跨端问题,故用固定用例表钉死。
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { assertKeyId, assertProduct, deriveKeyId, encodePerson, KEY_ID_RE } from "../src/app/api/publish/_shared/identity";
|
|
6
|
+
|
|
7
|
+
describe("发布身份推导", () => {
|
|
8
|
+
it("given 用户名 wecom:everpan 且产品 demo-solution When 推导 Then key_id 用连字符替换冒号", () => {
|
|
9
|
+
expect(deriveKeyId({ product: "demo-solution", username: "wecom:everpan", uid: 7 }))
|
|
10
|
+
.toBe("demo-solution.wecom-everpan");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("given 用户名含大写/下划线/连续符号 When 推导 Then 全小写、非白名单字符替换为 - 并折叠", () => {
|
|
14
|
+
expect(encodePerson("WeCom:Ever_Pan", 7)).toBe("wecom-ever-pan");
|
|
15
|
+
expect(encodePerson("--a__b--", 7)).toBe("a-b");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("given 用户名归一化后为空(纯中文)When 推导 Then 退化为 u<uid> 且仍是合法 key_id", () => {
|
|
19
|
+
const keyId = deriveKeyId({ product: "demo-solution", username: "张三", uid: 42 });
|
|
20
|
+
expect(keyId).toBe("demo-solution.u42");
|
|
21
|
+
expect(KEY_ID_RE.test(keyId)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("given 产品名为保留名 When 校验 Then 拒绝并提示换成自己的解决方案名", () => {
|
|
25
|
+
for (const reserved of ["oj", "market", "system", "registry"])
|
|
26
|
+
expect(() => assertProduct(reserved)).toThrowError(/保留名/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("given 产品名形状非法 When 校验 Then 拒绝并给出可用示例", () => {
|
|
30
|
+
for (const bad of ["", " ", "Demo", "1demo", "demo_solution", "demo solution", "a".repeat(65)])
|
|
31
|
+
expect(() => assertProduct(bad)).toThrowError(/400|产品名/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("given 合法产品名带首尾空格 When 校验 Then 去空格后通过", () => {
|
|
35
|
+
expect(assertProduct(" demo-solution ")).toBe("demo-solution");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("given 推导结果 When 自检 Then 满足 C2-37 的 key_id 形状(≥2 段、小写、仅 [a-z0-9-])", () => {
|
|
39
|
+
const keyId = assertKeyId(deriveKeyId({ product: "demo-solution", username: "wecom:everpan", uid: 7 }));
|
|
40
|
+
expect(keyId.split(".")).toHaveLength(2);
|
|
41
|
+
expect(KEY_ID_RE.test(keyId)).toBe(true);
|
|
42
|
+
expect(() => assertKeyId("nodot")).toThrowError(/不符合 key_id 规则/);
|
|
43
|
+
});
|
|
44
|
+
});
|