@oj-module/cli 0.1.21 → 0.2.1
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 +1 -1
- package/devkit/SKILL.md +17 -3
- package/devkit/manual.md +77 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index-BUyoDvUl.js → index-CLdhl3Qd.js} +61 -12
- package/shell-dist/assets/runtime.js +226 -89
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/build.ts +25 -0
- package/src/init.ts +3 -0
- package/templates/README.md +15 -0
- package/templates/api/config.yaml +14 -0
- package/templates/api/seed.mysql.sql +5 -0
- package/templates/api/seed.pg.sql +5 -0
- package/templates/api/src/_platform/_shared/respond.ts +18 -0
- package/templates/api/src/_platform/_shared/service/access.ts +99 -0
- package/templates/api/src/_platform/_shared/service/audit.ts +90 -0
- package/templates/api/src/_platform/_shared/service/contacts.ts +36 -0
- package/templates/api/src/_platform/_shared/service/guard.ts +30 -0
- package/templates/api/src/_platform/_shared/service/oplog.ts +47 -0
- package/templates/api/src/_platform/_shared/service/query.ts +53 -0
- package/templates/api/src/_platform/_shared/service/rate.ts +79 -0
- package/templates/api/src/_platform/_shared/service/roles.ts +137 -0
- package/templates/api/src/_platform/_shared/service/settings.ts +67 -0
- package/templates/api/src/_platform/_shared/service/solution-perms.ts +94 -0
- package/templates/api/src/_platform/_shared/service/users.ts +163 -0
- package/templates/api/src/_platform/contacts/api.ts +22 -0
- package/templates/api/src/_platform/contacts/detail/api.ts +18 -0
- package/templates/api/src/_platform/manifest.yaml +9 -1
- package/templates/api/src/_platform/migrations/0001__create_users.mysql.sql +9 -0
- package/templates/api/src/_platform/migrations/0001__create_users.pg.sql +9 -0
- package/templates/api/src/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +3 -0
- package/templates/api/src/_platform/migrations/0003__users_source_display_name.mysql.sql +5 -0
- package/templates/api/src/_platform/migrations/0003__users_source_display_name.sql +5 -0
- package/templates/api/src/_platform/migrations/0004__sys_rbac.mysql.sql +70 -0
- package/templates/api/src/_platform/migrations/0004__sys_rbac.pg.sql +67 -0
- package/templates/api/src/_platform/migrations/0004__sys_rbac.sql +67 -0
- package/templates/api/src/_platform/migrations/0005__users_must_change.mysql.sql +3 -0
- package/templates/api/src/_platform/migrations/0005__users_must_change.sql +2 -0
- package/templates/api/src/_platform/migrations/0006__users_is_forbidden.mysql.sql +3 -0
- package/templates/api/src/_platform/migrations/0006__users_is_forbidden.sql +3 -0
- package/templates/api/src/_platform/migrations/0007__users_email.sql +3 -0
- package/templates/api/src/_platform/migrations/0008__sys_refresh_token.mysql.sql +10 -0
- package/templates/api/src/_platform/migrations/0008__sys_refresh_token.pg.sql +10 -0
- package/templates/api/src/_platform/migrations/0008__sys_refresh_token.sql +11 -0
- package/templates/api/src/_platform/migrations/0009__sys_throttle.mysql.sql +12 -0
- package/templates/api/src/_platform/migrations/0009__sys_throttle.pg.sql +12 -0
- package/templates/api/src/_platform/migrations/0009__sys_throttle.sql +13 -0
- package/templates/api/src/_platform/schema.yaml +76 -1
- package/templates/api/src/_platform/seed.mysql.sql +8 -0
- package/templates/api/src/_platform/seed.pg.sql +8 -0
- package/templates/api/src/_platform/seed.sql +19 -2
- package/templates/api/src/auth/_shared/ldap-login.ts +86 -0
- package/templates/api/src/auth/_shared/ldap.ts +14 -0
- package/templates/api/src/auth/_shared/login-throttle.ts +29 -0
- package/templates/api/src/auth/_shared/session.ts +163 -18
- package/templates/api/src/auth/api.ts +54 -44
- package/templates/api/src/auth/ldap-login/api.ts +29 -0
- package/templates/api/src/system/login-methods/api.ts +19 -0
- package/templates/api/src/system/manifest.yaml +5 -0
- package/templates/api/src/system/oplog/api.ts +53 -0
- package/templates/api/src/system/permissions/api.ts +26 -0
- package/templates/api/src/system/roles/api.ts +75 -0
- package/templates/api/src/system/roles/bindings/api.ts +29 -0
- package/templates/api/src/system/settings/api.ts +60 -0
- package/templates/api/src/system/users/api.ts +58 -0
- package/templates/api/src/system/users/forbidden/api.ts +26 -0
- package/templates/api/src/system/users/password/api.ts +26 -0
- package/templates/api/src/system/users/roles/api.ts +27 -0
- package/templates/api/src/web/user-info/api.ts +9 -18
- package/templates/api/tests/auth-ldap.spec.ts +173 -0
- package/templates/api/tests/auth-session.spec.ts +194 -0
- package/templates/api/tests/auth-throttle.spec.ts +74 -0
- package/templates/api/tests/fixture.ts +50 -0
- package/templates/api/tests/guard.spec.ts +62 -0
- package/templates/api/tests/query-utils.spec.ts +68 -0
- package/templates/api/tests/rbac-audit.spec.ts +105 -0
- package/templates/api/tests/rbac-contacts.spec.ts +85 -0
- package/templates/api/tests/rbac-guard.spec.ts +192 -0
- package/templates/api/tests/rbac-roles.spec.ts +159 -0
- package/templates/api/tests/rbac-seed.spec.ts +54 -0
- package/templates/api/tests/rbac-settings.spec.ts +79 -0
- package/templates/api/tests/rbac-solution-perms.spec.ts +168 -0
- package/templates/api/tests/rbac-tables.spec.ts +63 -0
- package/templates/api/tests/rbac-users.spec.ts +164 -0
- package/templates/api/vitest.config.ts +12 -0
- package/templates/openapi.json +1060 -31
- package/templates/pnpm-workspace.yaml +9 -0
- package/templates/web/src/auth/__client/api.schemas.ts +6 -1
- package/templates/web/src/auth/__client/api.ts +33 -1
- package/templates/web/src/login/entry.ts +17 -0
- package/templates/web/src/login/locales/en-US.json +12 -2
- package/templates/web/src/login/locales/zh-CN.json +12 -2
- package/templates/web/src/login/pages/change-password.tsx +74 -0
- package/templates/web/src/system/entry.ts +90 -0
- package/templates/web/src/system/locales/en-US.json +98 -0
- package/templates/web/src/system/locales/zh-CN.json +98 -0
- package/templates/web/src/system/pages/errors.ts +51 -0
- package/templates/web/src/system/pages/forbidden.tsx +35 -0
- package/templates/web/src/system/pages/login-methods/index.tsx +29 -0
- package/templates/web/src/system/pages/oplog/index.tsx +91 -0
- package/templates/web/src/system/pages/roles/index.tsx +91 -0
- package/templates/web/src/system/pages/settings/index.tsx +106 -0
- package/templates/web/src/system/pages/users/index.tsx +168 -0
- package/templates/web/src/system/pages/users/roles.tsx +68 -0
- package/templates/web.config.ts +8 -0
- package/vendor/host-versions.json +3 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// BDD:角色管理应用服务——CRUD 契约、命名空间红线、binding 编辑(含紧急吊销=写时失效)、占用禁删、写审计。
|
|
2
|
+
// AC 锚点:AC1-16/17(建角色)、AC1-18(声明角色绑定可调整)、AC1-19(builtin 无编辑入口)、
|
|
3
|
+
// AC1-20(占用禁删)、AC1-28(写审计三动作)、AC1-30(摘绑定下一次请求即 403)。
|
|
4
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
5
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { assertPermission } from "../src/_platform/_shared/service/access";
|
|
8
|
+
import { createRole, deleteRole, listPermissions, listRoles, updateBindings } from "../src/_platform/_shared/service/roles";
|
|
9
|
+
import { installDb } from "./fixture";
|
|
10
|
+
|
|
11
|
+
let dbo: ReturnType<typeof installDb>;
|
|
12
|
+
const g = globalThis as any;
|
|
13
|
+
|
|
14
|
+
function mkUser(username: string): number {
|
|
15
|
+
dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(username);
|
|
16
|
+
return (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function bindRole(userId: number, role: string) {
|
|
20
|
+
dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(userId, role);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function seedPerm(code: string, deprecated = 0) {
|
|
24
|
+
dbo.prepare("INSERT OR IGNORE INTO sys_permission (code, module, deprecated) VALUES (?, ?, ?)")
|
|
25
|
+
.run(code, code.split(".")[0], deprecated);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function oplog(action: string) {
|
|
29
|
+
return dbo.prepare("SELECT * FROM sys_oplog WHERE action = ? ORDER BY id").all(action) as Record<string, unknown>[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
dbo = installDb();
|
|
34
|
+
g.http = { user: { id: 1 } }; // admin 操作者(审计 username 解析用)
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
dbo.exec("DELETE FROM sys_role_permission");
|
|
39
|
+
dbo.exec("DELETE FROM sys_role");
|
|
40
|
+
dbo.exec("DELETE FROM sys_user_role");
|
|
41
|
+
dbo.exec("DELETE FROM sys_permission");
|
|
42
|
+
dbo.exec("DELETE FROM sys_oplog");
|
|
43
|
+
dbo.exec("DELETE FROM users WHERE username LIKE 'u%'");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("AC1-16/17:建角色(custom 命名空间强制 + 绑定写入)", () => {
|
|
47
|
+
it("建 custom.reviewer 并勾选绑定:sys_role + sys_role_permission 双写,module=custom", async () => {
|
|
48
|
+
seedPerm("wiki.doc.create");
|
|
49
|
+
seedPerm("crm.contact.create");
|
|
50
|
+
const role = await createRole({
|
|
51
|
+
name: "custom.reviewer",
|
|
52
|
+
title: "内容审核员",
|
|
53
|
+
permissions: ["wiki.doc.create", "crm.contact.create"],
|
|
54
|
+
});
|
|
55
|
+
expect(role.name).toBe("custom.reviewer");
|
|
56
|
+
expect(role.module).toBe("custom");
|
|
57
|
+
const row = dbo.prepare("SELECT builtin FROM sys_role WHERE name = 'custom.reviewer'").get() as { builtin: number };
|
|
58
|
+
expect(Number(row.builtin)).toBe(0);
|
|
59
|
+
const binds = dbo.prepare("SELECT permission_code FROM sys_role_permission WHERE role_name = 'custom.reviewer' ORDER BY permission_code").all();
|
|
60
|
+
expect(binds.map((b: any) => b.permission_code)).toEqual(["crm.contact.create", "wiki.doc.create"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("AC1-17 保留名/ solutions 命名空间/裸名一律 400", async () => {
|
|
64
|
+
await expect(createRole({ name: "admin", title: "x" })).rejects.toMatchObject({ status: 400 });
|
|
65
|
+
await expect(createRole({ name: "wiki.admin", title: "x" })).rejects.toMatchObject({ status: 400 });
|
|
66
|
+
await expect(createRole({ name: "reviewer", title: "x" })).rejects.toMatchObject({ status: 400 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("不存在的权限点拒绝(绑定必须落真实权限点)", async () => {
|
|
70
|
+
await expect(createRole({ name: "custom.ops", title: "x", permissions: ["ghost.act"] }))
|
|
71
|
+
.rejects
|
|
72
|
+
.toMatchObject({ status: 400 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("AC1-28 role.create 写审计(module=framework)", async () => {
|
|
76
|
+
await createRole({ name: "custom.audited", title: "审计钉" });
|
|
77
|
+
const [r] = oplog("role.create");
|
|
78
|
+
expect(r).toBeDefined();
|
|
79
|
+
expect(r.module).toBe("framework");
|
|
80
|
+
expect(r.target).toBe("custom.audited");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("AC1-18/19/30:绑定编辑", () => {
|
|
85
|
+
it("AC1-18 声明角色绑定可调整:替换写 sys_role_permission", async () => {
|
|
86
|
+
dbo.prepare("INSERT INTO sys_role (name, module, builtin) VALUES ('wiki.editor', 'wiki', 0)").run();
|
|
87
|
+
seedPerm("wiki.doc.create");
|
|
88
|
+
seedPerm("wiki.doc.delete");
|
|
89
|
+
await updateBindings("wiki.editor", ["wiki.doc.delete"]);
|
|
90
|
+
const codes = dbo.prepare("SELECT permission_code FROM sys_role_permission WHERE role_name = 'wiki.editor'").all();
|
|
91
|
+
expect(codes.map((c: any) => c.permission_code)).toEqual(["wiki.doc.delete"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("AC1-19 builtin 角色(superadmin/admin/editor/viewer)绑定编辑 403 硬拒", async () => {
|
|
95
|
+
dbo.prepare("INSERT INTO sys_role (name, module, builtin) VALUES ('editor', 'framework', 1)").run();
|
|
96
|
+
await expect(updateBindings("editor", ["wiki.doc.create"])).rejects.toMatchObject({ status: 403 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("AC1-30 紧急吊销:superadmin 摘绑定 → 持有者下一次判权即 403(写时失效)", async () => {
|
|
100
|
+
seedPerm("wiki.doc.create");
|
|
101
|
+
await createRole({ name: "custom.dave", title: "dave", permissions: ["wiki.doc.create"] });
|
|
102
|
+
const uid = mkUser("udave");
|
|
103
|
+
bindRole(uid, "custom.dave");
|
|
104
|
+
g.http = { user: { id: uid } };
|
|
105
|
+
await expect(assertPermission("wiki.doc.create")).resolves.toBeUndefined();
|
|
106
|
+
g.http = { user: { id: 1 } };
|
|
107
|
+
await updateBindings("custom.dave", []);
|
|
108
|
+
g.http = { user: { id: uid } };
|
|
109
|
+
await expect(assertPermission("wiki.doc.create")).rejects.toMatchObject({ status: 403 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("AC1-28 role.bindings.update 审计带增删明细", async () => {
|
|
113
|
+
seedPerm("wiki.a");
|
|
114
|
+
seedPerm("wiki.b");
|
|
115
|
+
await createRole({ name: "custom.diff", title: "x", permissions: ["wiki.a"] });
|
|
116
|
+
await updateBindings("custom.diff", ["wiki.b"]);
|
|
117
|
+
const [r] = oplog("role.bindings.update");
|
|
118
|
+
expect(JSON.parse(String(r.detail))).toEqual({ added: ["wiki.b"], removed: ["wiki.a"] });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("AC1-20:占用禁删", () => {
|
|
123
|
+
it("有持有者的角色删除被拒并给出人数;清空后可删", async () => {
|
|
124
|
+
await createRole({ name: "custom.held", title: "x" });
|
|
125
|
+
const u1 = mkUser("u1");
|
|
126
|
+
const u2 = mkUser("u2");
|
|
127
|
+
bindRole(u1, "custom.held");
|
|
128
|
+
bindRole(u2, "custom.held");
|
|
129
|
+
await expect(deleteRole("custom.held")).rejects.toMatchObject({ status: 409, holders: 2 });
|
|
130
|
+
dbo.exec("DELETE FROM sys_user_role WHERE role_name = 'custom.held'");
|
|
131
|
+
await expect(deleteRole("custom.held")).resolves.toBeUndefined();
|
|
132
|
+
expect(dbo.prepare("SELECT * FROM sys_role WHERE name = 'custom.held'").get()).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("builtin 角色删除 403;AC1-28 role.delete 审计落表", async () => {
|
|
136
|
+
dbo.prepare("INSERT INTO sys_role (name, module, builtin) VALUES ('viewer', 'framework', 1)").run();
|
|
137
|
+
await expect(deleteRole("viewer")).rejects.toMatchObject({ status: 403 });
|
|
138
|
+
await createRole({ name: "custom.todel", title: "x" });
|
|
139
|
+
await deleteRole("custom.todel");
|
|
140
|
+
expect(oplog("role.delete").length).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("列表支撑(C2 页面数据源)", () => {
|
|
145
|
+
it("listRoles 带持有计数与绑定数;listPermissions 带 deprecated 标灰标记", async () => {
|
|
146
|
+
seedPerm("wiki.old", 1);
|
|
147
|
+
seedPerm("wiki.new");
|
|
148
|
+
await createRole({ name: "custom.ls", title: "列表", permissions: ["wiki.old"] });
|
|
149
|
+
const uid = mkUser("uls");
|
|
150
|
+
bindRole(uid, "custom.ls");
|
|
151
|
+
const roles = await listRoles();
|
|
152
|
+
const r = roles.find(x => x.name === "custom.ls");
|
|
153
|
+
expect(r?.holders).toBe(1);
|
|
154
|
+
expect(r?.permissions).toEqual(["wiki.old"]);
|
|
155
|
+
const perms = await listPermissions();
|
|
156
|
+
expect(perms.find(p => p.code === "wiki.old")?.deprecated).toBe(true);
|
|
157
|
+
expect(perms.find(p => p.code === "wiki.new")?.deprecated).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
// BDD:seed 契约——框架内置角色 + seed 超管绑定 + must_change 首登改密(D12/AC1-27)。
|
|
5
|
+
// 夹具重放真实 migrations + seed.sql 文件,本 spec 即「seed 与文件不漂移」的钉子。
|
|
6
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { installDb } from "./fixture";
|
|
9
|
+
|
|
10
|
+
const SEED = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "_platform", "seed.sql");
|
|
11
|
+
let dbo: ReturnType<typeof installDb>;
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
dbo = installDb();
|
|
15
|
+
// 清空夹具预插的 users,让 seed 成为 users 数据的唯一来源(INSERT OR IGNORE 依赖空表语义)
|
|
16
|
+
dbo.prepare("DELETE FROM users").run();
|
|
17
|
+
// seed 朴素按分号切分(与 oj 运行时同规则;注释内禁英文分号是 seed.sql 头的纪律);
|
|
18
|
+
// 每个语句块先剥掉整行注释再执行(注释与语句同行出现是 seed 文件自身的纪律约束)
|
|
19
|
+
for (const stmt of readFileSync(SEED, "utf8").split(";")) {
|
|
20
|
+
const sql = stmt.split("\n").filter(l => !l.trim().startsWith("--")).join("\n").trim();
|
|
21
|
+
if (sql)
|
|
22
|
+
dbo.exec(sql);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("框架内置角色(ROLES 常量单一真相,PRD §4.2)", () => {
|
|
27
|
+
it.each([
|
|
28
|
+
["superadmin", "超级管理员"],
|
|
29
|
+
["admin", "系统管理员"],
|
|
30
|
+
["editor", "编辑"],
|
|
31
|
+
["viewer", "访客"],
|
|
32
|
+
])("seed 出 builtin 角色 %s", (name, title) => {
|
|
33
|
+
const row = dbo.prepare("SELECT * FROM sys_role WHERE name = ?").get(name) as Record<string, unknown>;
|
|
34
|
+
expect(row).toBeDefined();
|
|
35
|
+
expect(row.module).toBe("framework");
|
|
36
|
+
expect(Number(row.builtin)).toBe(1);
|
|
37
|
+
expect(row.title).toBe(title);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("seed 超管(D12)", () => {
|
|
42
|
+
// AUTOINCREMENT 不复用 id(sqlite_sequence 语义),统一按 username 动态解析,不钉死 id
|
|
43
|
+
const adminId = () => (dbo.prepare("SELECT id FROM users WHERE username = 'admin'").get() as { id: number }).id;
|
|
44
|
+
|
|
45
|
+
it("admin 账号绑定 superadmin 角色(sys_user_role 权威源)", () => {
|
|
46
|
+
const row = dbo.prepare("SELECT * FROM sys_user_role WHERE user_id = ? AND role_name = 'superadmin'").get(adminId());
|
|
47
|
+
expect(row).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("must_change=1:首登强制改密(AC1-27 前提)", () => {
|
|
51
|
+
const row = dbo.prepare("SELECT must_change FROM users WHERE id = ?").get(adminId()) as { must_change: number };
|
|
52
|
+
expect(Number(row.must_change)).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// BDD:后端 C 期补口——操作日志查询(AC1-29/D13 admin 可查)、系统设置(PRD §4.3)、登录方式发现(C5/D2 匿名)。
|
|
2
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
3
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { searchOplog } from "../src/_platform/_shared/service/oplog";
|
|
6
|
+
import { listLoginMethods, listSettings, updateSetting } from "../src/_platform/_shared/service/settings";
|
|
7
|
+
import { installDb } from "./fixture";
|
|
8
|
+
|
|
9
|
+
let dbo: ReturnType<typeof installDb>;
|
|
10
|
+
const g = globalThis as any;
|
|
11
|
+
|
|
12
|
+
function mkOplog(username: string, action: string, module: string, level = 6) {
|
|
13
|
+
dbo.prepare("INSERT INTO sys_oplog (create_time, user_id, username, action, module, level) VALUES (?, 1, ?, ?, ?, ?)")
|
|
14
|
+
.run(1000, username, action, module, level);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
dbo = installDb();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
g.http = {};
|
|
23
|
+
dbo.exec("DELETE FROM sys_oplog");
|
|
24
|
+
dbo.exec("DELETE FROM sys_setting");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("AC1-29/D13:操作日志查询(admin 可查)", () => {
|
|
28
|
+
it("按 action/username/module 过滤 + 分页截断标记", async () => {
|
|
29
|
+
mkOplog("alice", "role.create", "framework");
|
|
30
|
+
mkOplog("alice", "user.create", "framework");
|
|
31
|
+
mkOplog("bob", "role.create", "framework");
|
|
32
|
+
const all = await searchOplog({});
|
|
33
|
+
expect(all.total).toBe(3);
|
|
34
|
+
const byAction = await searchOplog({ action: "user.create" });
|
|
35
|
+
expect(byAction.list.map(r => r.username)).toEqual(["alice"]);
|
|
36
|
+
const byUser = await searchOplog({ username: "bob" });
|
|
37
|
+
expect(byUser.total).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("403 拒绝类(auth.deny)可在日志中检索", async () => {
|
|
41
|
+
mkOplog("mallory", "auth.deny", "wiki", 4);
|
|
42
|
+
const r = await searchOplog({ action: "auth.deny" });
|
|
43
|
+
expect(r.total).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("PRD §4.3:系统设置", () => {
|
|
48
|
+
it("按 group 返回设置项(secret 项掩码)", async () => {
|
|
49
|
+
dbo.prepare("INSERT INTO sys_setting (key, module, \"group\", type, value, secret, update_time) VALUES ('framework.site_title', 'framework', 'general', 'string', 'ACME', 0, 0)").run();
|
|
50
|
+
dbo.prepare("INSERT INTO sys_setting (key, module, \"group\", type, value, secret, update_time) VALUES ('market.cdn_url', 'market', 'security', 'string', 'https://cdn.example.com', 1, 0)").run();
|
|
51
|
+
const groups = await listSettings();
|
|
52
|
+
const general = groups.find(x => x.group === "general");
|
|
53
|
+
expect(general?.items.find(i => i.key === "framework.site_title")?.value).toBe("ACME");
|
|
54
|
+
const security = groups.find(x => x.group === "security");
|
|
55
|
+
expect(security?.items.find(i => i.key === "market.cdn_url")?.value).toBe("***");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("更新写值 + update_time + 审计(market 安全项须 superadmin 操作者)", async () => {
|
|
59
|
+
dbo.prepare("INSERT INTO sys_setting (key, module, \"group\", type, value, secret, update_time) VALUES ('framework.site_title', 'framework', 'general', 'string', 'ACME', 0, 0)").run();
|
|
60
|
+
dbo.prepare("INSERT INTO sys_setting (key, module, \"group\", type, value, secret, update_time) VALUES ('market.cdn_url', 'market', 'security', 'string', 'x', 1, 0)").run();
|
|
61
|
+
await updateSetting(1, "framework.site_title", "NEW");
|
|
62
|
+
expect(String((dbo.prepare("SELECT value FROM sys_setting WHERE key = 'framework.site_title'").get() as { value: string }).value)).toBe("NEW");
|
|
63
|
+
expect(dbo.prepare("SELECT * FROM sys_oplog WHERE action = 'setting.update'").get()).toBeDefined();
|
|
64
|
+
// 非 superadmin 操作 market 安全项 → 403
|
|
65
|
+
await expect(updateSetting(1, "market.cdn_url", "https://evil.example.com")).rejects.toMatchObject({ status: 403 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("未知 key → 404", async () => {
|
|
69
|
+
await expect(updateSetting(1, "ghost.key", "v")).rejects.toMatchObject({ status: 404 });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("C5/D2:登录方式发现(匿名端点数据源)", () => {
|
|
74
|
+
it("默认仅 local;LDAP/SSO 开关来自设置项", async () => {
|
|
75
|
+
expect((await listLoginMethods()).methods).toEqual(["local"]);
|
|
76
|
+
dbo.prepare("INSERT INTO sys_setting (key, module, \"group\", type, value, update_time) VALUES ('ldap.enabled', 'ldap', 'login', 'bool', '1', 0)").run();
|
|
77
|
+
expect((await listLoginMethods()).methods).toEqual(["local", "ldap"]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// BDD:权限数据升级稳定性(设计 §5 / AC2-1~6)——「模拟安装器 upsert 逻辑」单元层钉死规则。
|
|
2
|
+
// 安装器本体属 PRD M5;本文件钉死的是安装器必须调用的框架规则出口(upsert/uninstall),
|
|
3
|
+
// 端到端(真 manifest 走真安装器)延后至 M5 验收。
|
|
4
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
5
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { uninstallSolution, upsertSolutionPerms } from "../src/_platform/_shared/service/solution-perms";
|
|
8
|
+
import { installDb } from "./fixture";
|
|
9
|
+
|
|
10
|
+
let dbo: ReturnType<typeof installDb>;
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
dbo = installDb();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
dbo.exec("DELETE FROM sys_role_permission");
|
|
18
|
+
dbo.exec("DELETE FROM sys_user_role WHERE role_name != 'superadmin'"); // 夹具超管绑定常驻
|
|
19
|
+
dbo.exec("DELETE FROM users WHERE username LIKE 'u_%'");
|
|
20
|
+
dbo.exec("DELETE FROM sys_permission");
|
|
21
|
+
dbo.exec("DELETE FROM sys_role WHERE builtin = 0");
|
|
22
|
+
dbo.exec("UPDATE sys_role SET deprecated = 0");
|
|
23
|
+
dbo.exec("UPDATE sys_permission SET deprecated = 0");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** 模拟 wiki 0.1.9 首次安装:声明 editor/admin 两角色与两个权限点。 */
|
|
27
|
+
async function installWikiV019() {
|
|
28
|
+
await upsertSolutionPerms({
|
|
29
|
+
module: "wiki",
|
|
30
|
+
roles: [
|
|
31
|
+
{ name: "wiki.admin", title: "管理员", permissions: ["wiki.doc.create", "wiki.space.create"] },
|
|
32
|
+
{ name: "wiki.editor", title: "编辑", permissions: ["wiki.doc.create"] },
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rows(sql: string) {
|
|
38
|
+
return dbo.prepare(sql).all() as Record<string, unknown>[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("AC2-1/2/5:升级只做加法与元数据覆盖", () => {
|
|
42
|
+
it("AC2-1 新增声明 → upsert 插入新行;既有绑定与判决不变", async () => {
|
|
43
|
+
await installWikiV019();
|
|
44
|
+
// 管理员手动撤掉 wiki.editor 的 wiki.doc.create(AC1-15 前态)
|
|
45
|
+
dbo.exec("DELETE FROM sys_role_permission WHERE role_name = 'wiki.editor' AND permission_code = 'wiki.doc.create'");
|
|
46
|
+
const before = rows("SELECT * FROM sys_role_permission ORDER BY role_name, permission_code");
|
|
47
|
+
const r = await upsertSolutionPerms({
|
|
48
|
+
module: "wiki",
|
|
49
|
+
roles: [
|
|
50
|
+
{ name: "wiki.admin", title: "管理员", permissions: ["wiki.doc.create", "wiki.space.create"] },
|
|
51
|
+
{ name: "wiki.editor", title: "编辑", permissions: ["wiki.doc.create"] },
|
|
52
|
+
{ name: "wiki.guest", title: "访客", permissions: ["wiki.doc.read"] },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
// 新角色/权限点插入
|
|
56
|
+
expect(rows("SELECT * FROM sys_role WHERE name = 'wiki.guest'").length).toBe(1);
|
|
57
|
+
expect(rows("SELECT * FROM sys_permission WHERE code = 'wiki.doc.read'").length).toBe(1);
|
|
58
|
+
// 既有绑定零变动(AC2-5 零减法的一部分:升级不删 sys_role_permission 行);
|
|
59
|
+
// after = before + guest 新声明绑定恰好 1 行
|
|
60
|
+
const after = rows("SELECT * FROM sys_role_permission ORDER BY role_name, permission_code");
|
|
61
|
+
expect(after.length).toBe(before.length + 1);
|
|
62
|
+
for (const b of before)
|
|
63
|
+
expect(after).toContainEqual(b);
|
|
64
|
+
// guest 的声明绑定 insert-if-absent 写入
|
|
65
|
+
expect(rows("SELECT * FROM sys_role_permission WHERE role_name = 'wiki.guest' AND permission_code = 'wiki.doc.read'").length).toBe(1);
|
|
66
|
+
expect(r.deprecated).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("AC2-2 title 元数据覆盖;判决依据 name/code 不变", async () => {
|
|
70
|
+
await installWikiV019();
|
|
71
|
+
await upsertSolutionPerms({
|
|
72
|
+
module: "wiki",
|
|
73
|
+
roles: [
|
|
74
|
+
{ name: "wiki.admin", title: "管理员(新版)", permissions: ["wiki.doc.create", "wiki.space.create"] },
|
|
75
|
+
{ name: "wiki.editor", title: "编辑者(新版)", permissions: ["wiki.doc.create"] },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
expect((rows("SELECT title FROM sys_role WHERE name = 'wiki.editor'")[0]).title).toBe("编辑者(新版)");
|
|
79
|
+
expect(rows("SELECT * FROM sys_role_permission WHERE role_name = 'wiki.editor'").length).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("AC1-15 重复安装同一版本:管理员调整永不被声明覆写(insert-if-absent)", async () => {
|
|
83
|
+
await installWikiV019();
|
|
84
|
+
dbo.exec("DELETE FROM sys_role_permission WHERE role_name = 'wiki.editor' AND permission_code = 'wiki.doc.create'");
|
|
85
|
+
await installWikiV019(); // 同版本重装/重启重放
|
|
86
|
+
expect(rows("SELECT * FROM sys_role_permission WHERE role_name = 'wiki.editor'").length).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("AC2-3/4:声明消失与重命名 → deprecated 保留(判决不突变)", () => {
|
|
91
|
+
it("AC2-3 声明消失 → 行保留标 deprecated;持有者绑定继续生效(判权不过滤 deprecated)", async () => {
|
|
92
|
+
await installWikiV019();
|
|
93
|
+
const r = await upsertSolutionPerms({
|
|
94
|
+
module: "wiki",
|
|
95
|
+
roles: [
|
|
96
|
+
{ name: "wiki.admin", title: "管理员", permissions: ["wiki.doc.create"] }, // wiki.space.create 消失
|
|
97
|
+
{ name: "wiki.editor", title: "编辑", permissions: ["wiki.doc.create"] },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
const p = rows("SELECT deprecated FROM sys_permission WHERE code = 'wiki.space.create'");
|
|
101
|
+
expect(p.length).toBe(1); // 行保留
|
|
102
|
+
expect(Number(p[0].deprecated)).toBe(1); // 标 deprecated
|
|
103
|
+
// 既有绑定保留(判权链不过滤 deprecated——§5 双向过滤的方向红线)
|
|
104
|
+
expect(rows("SELECT * FROM sys_role_permission WHERE permission_code = 'wiki.space.create'").length).toBe(1);
|
|
105
|
+
expect(r.deprecated).toContain("wiki.space.create");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("AC2-4 重命名 = 旧 code 标 deprecated + 新 code 插入;安装器据 deprecated 清单强告警", async () => {
|
|
109
|
+
await installWikiV019();
|
|
110
|
+
const r = await upsertSolutionPerms({
|
|
111
|
+
module: "wiki",
|
|
112
|
+
roles: [
|
|
113
|
+
{ name: "wiki.admin", title: "管理员", permissions: ["wiki.doc.write", "wiki.space.create"] }, // doc.create → doc.write
|
|
114
|
+
{ name: "wiki.editor", title: "编辑", permissions: ["wiki.doc.write"] },
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
expect(Number(rows("SELECT deprecated FROM sys_permission WHERE code = 'wiki.doc.create'")[0].deprecated)).toBe(1);
|
|
118
|
+
expect(rows("SELECT * FROM sys_permission WHERE code = 'wiki.doc.write'").length).toBe(1);
|
|
119
|
+
// 旧绑定继续生效(不静默变),安装器拿到清单提示「需重新分配」
|
|
120
|
+
expect(rows("SELECT * FROM sys_role_permission WHERE permission_code = 'wiki.doc.create'").length).toBe(2);
|
|
121
|
+
expect(r.deprecated).toContain("wiki.doc.create");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("deprecated 角色同样保留标灰:角色行不删、绑定不删", async () => {
|
|
125
|
+
await installWikiV019();
|
|
126
|
+
await upsertSolutionPerms({
|
|
127
|
+
module: "wiki",
|
|
128
|
+
roles: [{ name: "wiki.admin", title: "管理员", permissions: ["wiki.doc.create", "wiki.space.create"] }], // editor 消失
|
|
129
|
+
});
|
|
130
|
+
expect(Number(rows("SELECT deprecated FROM sys_role WHERE name = 'wiki.editor'")[0].deprecated)).toBe(1);
|
|
131
|
+
expect(rows("SELECT * FROM sys_role_permission WHERE role_name = 'wiki.editor'").length).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("AC2-6:卸载级联清理(唯一减法出口)", () => {
|
|
136
|
+
it("双维度删除:wiki 角色及其绑定 + 属 wiki 的权限点绑定(含 custom 角色上的);custom 角色本体保留", async () => {
|
|
137
|
+
await installWikiV019();
|
|
138
|
+
// custom 角色跨模块绑了 wiki 权限点
|
|
139
|
+
dbo.exec("INSERT INTO sys_role (name, module, title) VALUES ('custom.reviewer', 'custom', '审核')");
|
|
140
|
+
dbo.exec("INSERT INTO sys_role_permission (role_name, permission_code) VALUES ('custom.reviewer', 'wiki.doc.create')");
|
|
141
|
+
// 两个用户持 wiki 角色
|
|
142
|
+
dbo.exec("INSERT INTO users (username, password_hash) VALUES ('u_carol', 'x'), ('u_dave', 'x')");
|
|
143
|
+
dbo.exec("INSERT INTO sys_user_role (user_id, role_name) SELECT id, 'wiki.editor' FROM users WHERE username = 'u_dave'");
|
|
144
|
+
dbo.exec("INSERT INTO sys_user_role (user_id, role_name) SELECT id, 'wiki.admin' FROM users WHERE username = 'u_carol'");
|
|
145
|
+
|
|
146
|
+
await uninstallSolution("wiki");
|
|
147
|
+
|
|
148
|
+
// 维度①:wiki 角色行 + 其绑定 + 用户成员资格全清
|
|
149
|
+
expect(rows("SELECT * FROM sys_role WHERE module = 'wiki'").length).toBe(0);
|
|
150
|
+
expect(rows("SELECT * FROM sys_role_permission WHERE role_name IN ('wiki.admin','wiki.editor')").length).toBe(0);
|
|
151
|
+
expect(rows("SELECT * FROM sys_user_role WHERE role_name IN ('wiki.admin','wiki.editor')").length).toBe(0);
|
|
152
|
+
// 维度②:permission_code 属 wiki 的绑定(custom 角色上的)也清——防重装复活旧授权
|
|
153
|
+
expect(rows("SELECT * FROM sys_role_permission WHERE permission_code LIKE 'wiki.%'").length).toBe(0);
|
|
154
|
+
expect(rows("SELECT * FROM sys_permission WHERE module = 'wiki'").length).toBe(0);
|
|
155
|
+
// custom 角色本体保留(空绑定)
|
|
156
|
+
expect(rows("SELECT * FROM sys_role WHERE name = 'custom.reviewer'").length).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("AC2-5 卸载也不动其他模块:framework builtin 角色与绑定原样", async () => {
|
|
160
|
+
await installWikiV019();
|
|
161
|
+
// 夹具只重放迁移不落 seed——显式补超管绑定作对照组
|
|
162
|
+
dbo.exec("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'superadmin' FROM users WHERE username = 'admin'");
|
|
163
|
+
const before = rows("SELECT * FROM sys_role WHERE module = 'framework'");
|
|
164
|
+
await uninstallSolution("wiki");
|
|
165
|
+
expect(rows("SELECT * FROM sys_role WHERE module = 'framework'")).toEqual(before);
|
|
166
|
+
expect(rows("SELECT * FROM sys_user_role WHERE role_name = 'superadmin'").length).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// BDD:RBAC 六表 + users 扩展列的迁移契约(验收锚点 AC1-7 的数据面;
|
|
2
|
+
// 设计文档 D10/§5.9.2)。夹具重放真实迁移,本 spec 即「迁移与表结构不漂移」的钉子。
|
|
3
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { installDb } from "./fixture";
|
|
6
|
+
|
|
7
|
+
let dbo: ReturnType<typeof installDb>;
|
|
8
|
+
|
|
9
|
+
function colsOf(table: string) {
|
|
10
|
+
return (dbo.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string, notnull: number, dflt_value: unknown }>).map(c => c.name);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
dbo = installDb();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("sys_* RBAC 表(设计文档 §5.9.2)", () => {
|
|
18
|
+
it.each([
|
|
19
|
+
"sys_setting",
|
|
20
|
+
"sys_oplog",
|
|
21
|
+
"sys_solution_route",
|
|
22
|
+
"sys_role",
|
|
23
|
+
"sys_permission",
|
|
24
|
+
"sys_role_permission",
|
|
25
|
+
"sys_user_role",
|
|
26
|
+
])("迁移建出 %s", (table) => {
|
|
27
|
+
expect(colsOf(table).length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("sys_role / sys_permission 带 deprecated 列(升级稳定性 §5 的地基)", () => {
|
|
31
|
+
expect(colsOf("sys_role")).toContain("deprecated");
|
|
32
|
+
expect(colsOf("sys_permission")).toContain("deprecated");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("sys_user_role 复合主键 (user_id, role_name)", () => {
|
|
36
|
+
const pk = dbo.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('sys_user_role') WHERE pk > 0").get() as { n: number };
|
|
37
|
+
expect(Number(pk.n)).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("sys_oplog 带 (create_time, id) 索引", () => {
|
|
41
|
+
const idx = dbo.prepare("SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='sys_oplog'").all() as Array<{ name: string, sql: string | null }>;
|
|
42
|
+
const time = idx.find(i => i.sql?.includes("create_time"));
|
|
43
|
+
expect(time).toBeDefined();
|
|
44
|
+
expect(time?.sql).toContain("id");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("users 表扩展(PRD §4.2 数据模型)", () => {
|
|
49
|
+
it("新增 source / display_name 列", () => {
|
|
50
|
+
expect(colsOf("users")).toContain("source");
|
|
51
|
+
expect(colsOf("users")).toContain("display_name");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("移除 users.roles JSON 列(D10:sys_user_role 为唯一权威源)", () => {
|
|
55
|
+
expect(colsOf("users")).not.toContain("roles");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("source 默认 local(外来号来源标记的默认值)", () => {
|
|
59
|
+
const info = dbo.prepare("PRAGMA table_info(users)").all() as Array<{ name: string, dflt_value: unknown }>;
|
|
60
|
+
const source = info.find(c => c.name === "source");
|
|
61
|
+
expect(String(source?.dflt_value)).toContain("local");
|
|
62
|
+
});
|
|
63
|
+
});
|