@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,50 @@
|
|
|
1
|
+
// 后端 BDD 夹具:node:sqlite 内存库 + **真实迁移文件**建表,装到 globalThis.db——
|
|
2
|
+
// 与 oj 运行时注入的全局同名同面(query/exec/tx/dialect),service 代码不改一行即可被测。
|
|
3
|
+
// 夹具直接读 api/src/<模块>/migrations/*.sql,与迁移不可能漂移(沿用 oj-wiki 先例)。
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
|
|
6
|
+
const req = createRequire(import.meta.url);
|
|
7
|
+
const { DatabaseSync } = req("node:sqlite");
|
|
8
|
+
const { readdirSync, readFileSync, existsSync } = req("node:fs");
|
|
9
|
+
const { join, dirname } = req("node:path");
|
|
10
|
+
const { fileURLToPath } = req("node:url");
|
|
11
|
+
|
|
12
|
+
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
|
|
13
|
+
|
|
14
|
+
export const ADMIN = 1;
|
|
15
|
+
|
|
16
|
+
/** 建内存库 + 注入全局;返回 DatabaseSync 供用例直插数据/断言。 */
|
|
17
|
+
export function installDb(): any {
|
|
18
|
+
const dbo = new DatabaseSync(":memory:");
|
|
19
|
+
// 重放 src 下全部模块的迁移(方言专属文件除外)
|
|
20
|
+
for (const mod of readdirSync(SRC)) {
|
|
21
|
+
const dir = join(SRC, mod, "migrations");
|
|
22
|
+
if (!existsSync(dir))
|
|
23
|
+
continue;
|
|
24
|
+
for (const f of readdirSync(dir).filter((f: string) => f.endsWith(".sql") && !/\.(?:mysql|pg)\.sql$/.test(f)).sort())
|
|
25
|
+
dbo.exec(readFileSync(join(dir, f), "utf8"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
dbo.prepare("INSERT INTO users (id, username, password_hash) VALUES (?, ?, 'x')").run(ADMIN, "admin");
|
|
29
|
+
|
|
30
|
+
const query = async (sql: string, params: unknown[] = []) => dbo.prepare(sql).all(...(params as never[]));
|
|
31
|
+
const exec = async (sql: string, params: unknown[] = []) => Number(dbo.prepare(sql).run(...(params as never[])).changes);
|
|
32
|
+
const txInstance = { query, exec, isTx: true };
|
|
33
|
+
// db.tx 镜像运行时面:BEGIN/COMMIT/ROLLBACK 包裹回调(versions.ts 注释同语义)
|
|
34
|
+
const tx = async (fn: (t: typeof txInstance) => unknown) => {
|
|
35
|
+
dbo.exec("BEGIN");
|
|
36
|
+
try {
|
|
37
|
+
const r = await fn(txInstance);
|
|
38
|
+
dbo.exec("COMMIT");
|
|
39
|
+
return r;
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
dbo.exec("ROLLBACK");
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const g = globalThis as any;
|
|
48
|
+
g.db = { query, exec, tx, dialect: () => "sqlite" };
|
|
49
|
+
return dbo;
|
|
50
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
2
|
+
// BDD:判权四级链(设计文档 §4,验收锚点 AC1-1~6)+ spike 夹具冒烟
|
|
3
|
+
// (真实迁移建表 / 运行时全局 db 同面 / 事务往返)——S0 spike 的「一条真实判权 AC + 形态证明」。
|
|
4
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { decideAccess, moduleOf } from "../src/_platform/_shared/service/guard";
|
|
7
|
+
import { ADMIN, installDb } from "./fixture";
|
|
8
|
+
|
|
9
|
+
const as = (roles: string[], bindings: Record<string, string[]> = {}) => ({ roles, bindings });
|
|
10
|
+
|
|
11
|
+
describe("判权四级链(AC1-1~6 判定矩阵)", () => {
|
|
12
|
+
it("AC1-1 平台超集:superadmin 未绑定任何权限点 → 通过", () => {
|
|
13
|
+
expect(decideAccess(as(["superadmin"]), "wiki.doc.create")).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it("AC1-2 系统超集:admin 未绑定任何权限点 → 通过", () => {
|
|
16
|
+
expect(decideAccess(as(["admin"]), "wiki.doc.create")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it("AC1-3 模块超集(零配置推导):wiki.admin 过 wiki.*", () => {
|
|
19
|
+
expect(decideAccess(as(["wiki.admin"]), "wiki.doc.create")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it("AC1-4 模块超集不外溢:wiki.admin 不过 crm.*", () => {
|
|
22
|
+
expect(decideAccess(as(["wiki.admin"]), "crm.contact.create")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
it("AC1-5 精确命中:wiki.editor 经绑定过 wiki.doc.create", () => {
|
|
25
|
+
expect(decideAccess(as(["wiki.editor"], { "wiki.editor": ["wiki.doc.create"] }), "wiki.doc.create")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it("AC1-6 无权限:viewer 无绑定 → false(handler 层转 403)", () => {
|
|
28
|
+
expect(decideAccess(as(["viewer"]), "wiki.doc.create")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
it("code 首段推导:无点 code 整体即模块名", () => {
|
|
31
|
+
expect(moduleOf("wiki")).toBe("wiki");
|
|
32
|
+
expect(moduleOf("wiki.doc.create")).toBe("wiki");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("spike 夹具冒烟:真实迁移 + 运行时全局面 + 事务", () => {
|
|
37
|
+
let dbo: ReturnType<typeof installDb>;
|
|
38
|
+
beforeAll(() => {
|
|
39
|
+
dbo = installDb();
|
|
40
|
+
});
|
|
41
|
+
it("真实迁移建 users 表(夹具与迁移不漂移)", () => {
|
|
42
|
+
const row = dbo.prepare("SELECT username FROM users WHERE id = ?").get(ADMIN) as { username: string };
|
|
43
|
+
expect(row.username).toBe("admin");
|
|
44
|
+
});
|
|
45
|
+
it("全局 db.query/exec 与运行时同面可用", async () => {
|
|
46
|
+
const rows = await db.query("SELECT id, username FROM users");
|
|
47
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
it("db.tx 事务往返:提交持久化 / 回滚不留痕", async () => {
|
|
50
|
+
await db.tx(async (t) => {
|
|
51
|
+
await t.exec("INSERT INTO users (username, password_hash) VALUES ('tx-ok', 'x')");
|
|
52
|
+
});
|
|
53
|
+
const ok = dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE username = 'tx-ok'").get() as { n: number };
|
|
54
|
+
expect(Number(ok.n)).toBe(1);
|
|
55
|
+
await expect(db.tx(async (t) => {
|
|
56
|
+
await t.exec("INSERT INTO users (username, password_hash) VALUES ('tx-bad', 'x')");
|
|
57
|
+
throw new Error("rollback");
|
|
58
|
+
})).rejects.toThrow("rollback");
|
|
59
|
+
const bad = dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE username = 'tx-bad'").get() as { n: number };
|
|
60
|
+
expect(Number(bad.n)).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// BDD:查询工具——paginate(limit 钳 200 / truncated)+ likePattern(keyword≥2、LIKE 转义)。
|
|
2
|
+
// AC 锚点:AC1-24(钳制与截断)、AC1-22(keyword<2 → 400)。ESCAPE '!' 三方言同语义(sqlite/mysql/pg)。
|
|
3
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
4
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { likePattern, paged } from "../src/_platform/_shared/service/query";
|
|
7
|
+
import { installDb } from "./fixture";
|
|
8
|
+
|
|
9
|
+
let dbo: ReturnType<typeof installDb>;
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
dbo = installDb();
|
|
13
|
+
for (let i = 1; i <= 300; i++)
|
|
14
|
+
dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(`u${String(i).padStart(3, "0")}`);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const LIST = "select id, username from users where 1=1";
|
|
18
|
+
const COUNT = "select count(*) as c from users where 1=1";
|
|
19
|
+
|
|
20
|
+
describe("AC1-24:limit 钳制与截断标记", () => {
|
|
21
|
+
it("size 静默钳 200,truncated=true(库 301 人=夹具 admin+300)", async () => {
|
|
22
|
+
const r = await paged<any>(LIST, COUNT, [], { page: 1, size: 500 });
|
|
23
|
+
expect(r.size).toBe(200);
|
|
24
|
+
expect(r.list.length).toBe(200);
|
|
25
|
+
expect(r.total).toBe(301);
|
|
26
|
+
expect(r.truncated).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("末页 truncated=false", async () => {
|
|
30
|
+
const r = await paged<any>(LIST, COUNT, [], { page: 2, size: 200 });
|
|
31
|
+
expect(r.list.length).toBe(101);
|
|
32
|
+
expect(r.truncated).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("缺省 page=1 / size=20;页码/尺寸非法值回落(0/负数/非数)", async () => {
|
|
36
|
+
const d = await paged<any>(LIST, COUNT, [], {});
|
|
37
|
+
expect(d.page).toBe(1);
|
|
38
|
+
expect(d.size).toBe(20);
|
|
39
|
+
const bad = await paged<any>(LIST, COUNT, [], { page: 0, size: -3 });
|
|
40
|
+
expect(bad.page).toBe(1);
|
|
41
|
+
expect(bad.size).toBe(20);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("AC1-22:LIKE 模式与 keyword 长度红线", () => {
|
|
46
|
+
it("keyword <2 字符 → 400", () => {
|
|
47
|
+
expect(() => likePattern("张")).toThrowError(expect.objectContaining({ status: 400 }));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keyword >64 字符 → 400", () => {
|
|
51
|
+
expect(() => likePattern("x".repeat(65))).toThrowError(expect.objectContaining({ status: 400 }));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("LIKE 元字符转义:% _ ! 原样匹配不泛化", async () => {
|
|
55
|
+
dbo.prepare("INSERT INTO users (username, password_hash) VALUES ('a%b_c!d', 'x')").run();
|
|
56
|
+
const r = await paged<any>(
|
|
57
|
+
"select id, username from users where username like ? escape '!'",
|
|
58
|
+
"select count(*) as c from users where username like ? escape '!'",
|
|
59
|
+
[likePattern("a%b_c!d")],
|
|
60
|
+
{ size: 10 },
|
|
61
|
+
);
|
|
62
|
+
expect(r.list.map((x: any) => x.username)).toEqual(["a%b_c!d"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("正常 keyword 两侧包裹 %", () => {
|
|
66
|
+
expect(likePattern("张三")).toBe("%张三%");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// BDD:审计出口——recordOp 落 sys_oplog 契约、auditOp fire-and-forget、403 分钟窗口聚合(AC1-29)。
|
|
2
|
+
// 夹具即真实库:断言 sys_oplog 行内容;fire-and-forget 用「DB 损坏也不抛出」验证。
|
|
3
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { auditDeny, auditOp, recordOp, resetDenyAudit } from "../src/_platform/_shared/service/audit";
|
|
7
|
+
import { installDb } from "./fixture";
|
|
8
|
+
|
|
9
|
+
let dbo: ReturnType<typeof installDb>;
|
|
10
|
+
const g = globalThis as any;
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
dbo = installDb();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
g.http = {};
|
|
20
|
+
resetDenyAudit();
|
|
21
|
+
dbo.exec("DELETE FROM sys_oplog");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function rows() {
|
|
25
|
+
return dbo.prepare("SELECT * FROM sys_oplog ORDER BY id").all() as Record<string, unknown>[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("recordOp:sys_oplog 落表契约", () => {
|
|
29
|
+
it("写全字段:action/module/version/target/detail(level=6 默认)", async () => {
|
|
30
|
+
g.http = { user: { id: 1 } }; // fixture admin(username='admin')
|
|
31
|
+
await recordOp({
|
|
32
|
+
action: "role.create",
|
|
33
|
+
module: "framework",
|
|
34
|
+
version: "0.2.0",
|
|
35
|
+
target: "custom.ops",
|
|
36
|
+
detail: { title: "运维" },
|
|
37
|
+
});
|
|
38
|
+
const [r] = rows();
|
|
39
|
+
expect(r.action).toBe("role.create");
|
|
40
|
+
expect(r.module).toBe("framework");
|
|
41
|
+
expect(r.version).toBe("0.2.0");
|
|
42
|
+
expect(r.target).toBe("custom.ops");
|
|
43
|
+
expect(JSON.parse(String(r.detail))).toEqual({ title: "运维" });
|
|
44
|
+
expect(Number(r.level)).toBe(6);
|
|
45
|
+
expect(Number(r.user_id)).toBe(1);
|
|
46
|
+
expect(r.username).toBe("admin");
|
|
47
|
+
expect(Number(r.create_time)).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("无用户(系统动作)→ username='system'、user_id=0", async () => {
|
|
51
|
+
await recordOp({ action: "solution.uninstall", module: "framework" });
|
|
52
|
+
const [r] = rows();
|
|
53
|
+
expect(r.username).toBe("system");
|
|
54
|
+
expect(Number(r.user_id)).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("auditOp:fire-and-forget", () => {
|
|
59
|
+
it("正常落表", async () => {
|
|
60
|
+
g.http = { user: { id: 1 } };
|
|
61
|
+
auditOp({ action: "user.roles.update", module: "framework", target: "u2" });
|
|
62
|
+
await vi.waitFor(() => expect(rows().length).toBe(1));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("DB 失败也不抛出(不阻断业务主链)", async () => {
|
|
66
|
+
g.http = { user: { id: 1 } };
|
|
67
|
+
vi.spyOn(g.db, "exec").mockRejectedValueOnce(new Error("db down"));
|
|
68
|
+
expect(() => auditOp({ action: "x.y", module: "framework" })).not.toThrow();
|
|
69
|
+
await new Promise(r => setImmediate(r));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("AC1-29:403 失败审计分钟窗口聚合(首条 + 计数,不逐条落表)", () => {
|
|
74
|
+
it("同 (user, code) 连续拒绝 3 次 → 1 行,detail.count=3", async () => {
|
|
75
|
+
g.http = { user: { id: 1 } };
|
|
76
|
+
auditDeny("wiki.page.delete");
|
|
77
|
+
auditDeny("wiki.page.delete");
|
|
78
|
+
auditDeny("wiki.page.delete");
|
|
79
|
+
await vi.waitFor(() => {
|
|
80
|
+
const rs = rows();
|
|
81
|
+
if (rs.length === 1 && JSON.parse(String(rs[0].detail)).count === 3)
|
|
82
|
+
return;
|
|
83
|
+
throw new Error("not yet");
|
|
84
|
+
});
|
|
85
|
+
expect(rows().length).toBe(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("窗口过期(>60s)后首条另起一行", async () => {
|
|
89
|
+
g.http = { user: { id: 1 } };
|
|
90
|
+
auditDeny("wiki.page.delete");
|
|
91
|
+
await vi.waitFor(() => expect(rows().length).toBe(1));
|
|
92
|
+
vi.setSystemTime(Date.now() + 61_000);
|
|
93
|
+
auditDeny("wiki.page.delete");
|
|
94
|
+
await vi.waitFor(() => expect(rows().length).toBe(2));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("不同 code / 不同用户各自独立聚合", async () => {
|
|
98
|
+
g.http = { user: { id: 1 } };
|
|
99
|
+
auditDeny("wiki.page.delete");
|
|
100
|
+
auditDeny("wiki.page.edit");
|
|
101
|
+
g.http = { user: { id: 2 } };
|
|
102
|
+
auditDeny("wiki.page.delete");
|
|
103
|
+
await vi.waitFor(() => expect(rows().length).toBe(3));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// BDD:通讯录——searchContacts 目录(AC1-22/23)+ contactDetail 详情 + 接口级限速。
|
|
2
|
+
// 读模型红线:目录只出 id/username/display_name(无 email/avatar/roles,安全评审 P2);
|
|
3
|
+
// 禁用用户不出目录(AC1-23);keyword≥2(AC1-22);limit 钳 200(AC1-24)。
|
|
4
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
5
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { contactDetail, searchContacts } from "../src/_platform/_shared/service/contacts";
|
|
8
|
+
import { installDb } from "./fixture";
|
|
9
|
+
|
|
10
|
+
let dbo: ReturnType<typeof installDb>;
|
|
11
|
+
const g = globalThis as any;
|
|
12
|
+
|
|
13
|
+
function mkUser(username: string, opts: { displayName?: string, email?: string, forbidden?: boolean } = {}): number {
|
|
14
|
+
dbo.prepare("INSERT INTO users (username, password_hash, display_name, email, is_forbidden) VALUES (?, 'x', ?, ?, ?)")
|
|
15
|
+
.run(username, opts.displayName ?? null, opts.email ?? null, opts.forbidden ? 1 : 0);
|
|
16
|
+
return (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
dbo = installDb();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.useRealTimers();
|
|
25
|
+
g.http = {};
|
|
26
|
+
dbo.exec("DELETE FROM users WHERE username LIKE 'c%'");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("AC1-22:登录可读的字段最小集", () => {
|
|
30
|
+
it("keyword 命中 username/display_name;返回仅 id/username/display_name", async () => {
|
|
31
|
+
mkUser("c_zhang", { displayName: "张三" });
|
|
32
|
+
mkUser("c_li", { displayName: "张三丰" });
|
|
33
|
+
mkUser("c_wang", { displayName: "王五" });
|
|
34
|
+
const byName = await searchContacts("c_zhang");
|
|
35
|
+
expect(byName.length).toBe(1);
|
|
36
|
+
expect(byName[0]).toEqual({ id: byName[0].id, username: "c_zhang", display_name: "张三" });
|
|
37
|
+
const byDisplay = await searchContacts("张三");
|
|
38
|
+
expect(byDisplay.map(u => u.username).sort()).toEqual(["c_li", "c_zhang"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keyword <2 字符 → 400", async () => {
|
|
42
|
+
await expect(searchContacts("张")).rejects.toMatchObject({ status: 400 });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("AC1-23:禁用用户不出目录", () => {
|
|
47
|
+
it("is_forbidden=1 不出现在结果中", async () => {
|
|
48
|
+
mkUser("c_carol", { displayName: "卡罗" });
|
|
49
|
+
mkUser("c_dave", { displayName: "卡罗 dave", forbidden: true });
|
|
50
|
+
const r = await searchContacts("卡罗");
|
|
51
|
+
expect(r.map(u => u.username)).toEqual(["c_carol"]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("AC1-24:limit 钳制", () => {
|
|
56
|
+
it("默认钳 200;传 500 静默钳 200", async () => {
|
|
57
|
+
for (let i = 0; i < 210; i++)
|
|
58
|
+
mkUser(`c_bulk${i}`);
|
|
59
|
+
const r = await searchContacts("c_bulk", { limit: 500 });
|
|
60
|
+
expect(r.length).toBe(200);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("详情与限速", () => {
|
|
65
|
+
it("contactDetail 含 email/avatar(目录不带的字段详情给)", async () => {
|
|
66
|
+
const uid = mkUser("c_eve", { email: "eve@example.com" });
|
|
67
|
+
dbo.prepare("UPDATE users SET avatar_base64 = 'aGVsbG8=' WHERE id = ?").run(uid);
|
|
68
|
+
const d = await contactDetail(uid);
|
|
69
|
+
expect(d.username).toBe("c_eve");
|
|
70
|
+
expect(d.email).toBe("eve@example.com");
|
|
71
|
+
expect(d.avatar_base64).toBe("aGVsbG8=");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("接口级限速:同用户突发超限 → 429", async () => {
|
|
75
|
+
const uid = mkUser("c_rate");
|
|
76
|
+
g.http = { user: { id: uid } };
|
|
77
|
+
const { rateLimit } = await import("../src/_platform/_shared/service/rate");
|
|
78
|
+
// 窗口 60s / 限额 3(测试参数;生产端点各自定额)
|
|
79
|
+
for (let i = 0; i < 3; i++)
|
|
80
|
+
await rateLimit(`contacts:${uid}`, 3, 60_000);
|
|
81
|
+
await expect(rateLimit(`contacts:${uid}`, 3, 60_000)).rejects.toMatchObject({ status: 429 });
|
|
82
|
+
vi.setSystemTime(Date.now() + 61_000);
|
|
83
|
+
await rateLimit(`contacts:${uid}`, 3, 60_000); // 窗口过期放行
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// BDD:判权出口——assertPermission 四级链(DB 权威源)+ 撤销即强退 + is_forbidden + 系统管理员断言。
|
|
2
|
+
// AC 锚点:AC1-1~6(判定矩阵走 DB 全链路)、AC1-9(assertSystemAdmin/assertSuperAdmin)、D10(缓存失效=撤销即强退)。
|
|
3
|
+
// 夹具即真实库:角色/绑定/禁用态全部 INSERT 进 sqlite,断言走 service 公开出口(不 mock 判定)。
|
|
4
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
5
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { assertPermission, assertSuperAdmin, assertSystemAdmin, invalidateUser } from "../src/_platform/_shared/service/access";
|
|
8
|
+
import { installDb } from "./fixture";
|
|
9
|
+
|
|
10
|
+
let dbo: ReturnType<typeof installDb>;
|
|
11
|
+
const g = globalThis as any;
|
|
12
|
+
|
|
13
|
+
/** 造用户(users 必填列最小集),返回 id。 */
|
|
14
|
+
function mkUser(username: string, opts: { forbidden?: boolean } = {}): number {
|
|
15
|
+
dbo.prepare("INSERT INTO users (username, password_hash, is_forbidden) VALUES (?, 'x', ?)")
|
|
16
|
+
.run(username, opts.forbidden ? 1 : 0);
|
|
17
|
+
return (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bindRole(userId: number, role: string) {
|
|
21
|
+
dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(userId, role);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function grant(role: string, code: string, module = "wiki") {
|
|
25
|
+
dbo.prepare("INSERT OR IGNORE INTO sys_permission (code, module) VALUES (?, ?)").run(code, module);
|
|
26
|
+
dbo.prepare("INSERT OR IGNORE INTO sys_role_permission (role_name, permission_code) VALUES (?, ?)").run(role, code);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function asUser(userId: number | undefined) {
|
|
30
|
+
g.http = userId === undefined ? {} : { user: { id: userId } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
dbo = installDb();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
invalidateAll();
|
|
39
|
+
asUser(undefined);
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** 每用例清空缓存与 RBAC 数据(users 保留,fixture admin id=1 无角色)。 */
|
|
44
|
+
function invalidateAll() {
|
|
45
|
+
for (const r of dbo.prepare("SELECT id FROM users").all() as { id: number }[])
|
|
46
|
+
invalidateUser(r.id);
|
|
47
|
+
dbo.exec("DELETE FROM sys_user_role");
|
|
48
|
+
dbo.exec("DELETE FROM sys_role_permission");
|
|
49
|
+
dbo.exec("DELETE FROM sys_permission");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("AC1-1~6:四级链经 DB 权威源判定", () => {
|
|
53
|
+
it("AC1-1 superadmin 通过任意 code(无需绑定)", async () => {
|
|
54
|
+
const uid = mkUser("u1");
|
|
55
|
+
bindRole(uid, "superadmin");
|
|
56
|
+
asUser(uid);
|
|
57
|
+
await expect(assertPermission("wiki.page.delete")).resolves.toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("AC1-2 admin 通过任意 code(无需绑定)", async () => {
|
|
61
|
+
const uid = mkUser("u2");
|
|
62
|
+
bindRole(uid, "admin");
|
|
63
|
+
asUser(uid);
|
|
64
|
+
await expect(assertPermission("any.thing")).resolves.toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("AC1-3 持 <模块>.admin 通过该模块全部 code(零配置推导)", async () => {
|
|
68
|
+
const uid = mkUser("u3");
|
|
69
|
+
bindRole(uid, "wiki.admin");
|
|
70
|
+
asUser(uid);
|
|
71
|
+
await expect(assertPermission("wiki.page.edit")).resolves.toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("AC1-3 负例:<模块>.admin 不越权其他模块", async () => {
|
|
75
|
+
const uid = mkUser("u3n");
|
|
76
|
+
bindRole(uid, "wiki.admin");
|
|
77
|
+
asUser(uid);
|
|
78
|
+
await expect(assertPermission("blog.page.edit")).rejects.toMatchObject({ status: 403 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("AC1-4 角色绑定精确命中 code 即通过", async () => {
|
|
82
|
+
const uid = mkUser("u4");
|
|
83
|
+
bindRole(uid, "editor");
|
|
84
|
+
grant("editor", "wiki.page.edit");
|
|
85
|
+
asUser(uid);
|
|
86
|
+
await expect(assertPermission("wiki.page.edit")).resolves.toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("AC1-5 绑定未命中即 403", async () => {
|
|
90
|
+
const uid = mkUser("u5");
|
|
91
|
+
bindRole(uid, "editor");
|
|
92
|
+
grant("editor", "wiki.page.edit");
|
|
93
|
+
asUser(uid);
|
|
94
|
+
await expect(assertPermission("wiki.page.delete")).rejects.toMatchObject({ status: 403 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("AC1-6 deprecated 权限点判权不过滤(已绑定角色照常通过)", async () => {
|
|
98
|
+
const uid = mkUser("u6");
|
|
99
|
+
bindRole(uid, "editor");
|
|
100
|
+
grant("editor", "wiki.legacy");
|
|
101
|
+
dbo.prepare("UPDATE sys_permission SET deprecated = 1 WHERE code = 'wiki.legacy'").run();
|
|
102
|
+
asUser(uid);
|
|
103
|
+
await expect(assertPermission("wiki.legacy")).resolves.toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("D10:撤销即强退(写时失效,TTL 不挡撤销)", () => {
|
|
108
|
+
it("撤销绑定 + invalidateUser → 下一次请求即 403", async () => {
|
|
109
|
+
const uid = mkUser("u7");
|
|
110
|
+
bindRole(uid, "editor");
|
|
111
|
+
grant("editor", "wiki.page.edit");
|
|
112
|
+
asUser(uid);
|
|
113
|
+
await expect(assertPermission("wiki.page.edit")).resolves.toBeUndefined();
|
|
114
|
+
dbo.prepare("DELETE FROM sys_role_permission WHERE role_name = 'editor'").run();
|
|
115
|
+
invalidateUser(uid);
|
|
116
|
+
await expect(assertPermission("wiki.page.edit")).rejects.toMatchObject({ status: 403 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("TTL 兜底:写后未失效时缓存命中(10s 内不重复查库语义)", async () => {
|
|
120
|
+
const uid = mkUser("u8");
|
|
121
|
+
bindRole(uid, "editor");
|
|
122
|
+
grant("editor", "wiki.page.edit");
|
|
123
|
+
asUser(uid);
|
|
124
|
+
await expect(assertPermission("wiki.page.edit")).resolves.toBeUndefined();
|
|
125
|
+
// 未失效:即使库中已撤销,缓存仍放行(写路径负责失效,TTL 只兜崩溃)
|
|
126
|
+
dbo.prepare("DELETE FROM sys_role_permission WHERE role_name = 'editor'").run();
|
|
127
|
+
await expect(assertPermission("wiki.page.edit")).resolves.toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("TTL 到期后重查库", async () => {
|
|
131
|
+
const uid = mkUser("u9");
|
|
132
|
+
bindRole(uid, "editor");
|
|
133
|
+
grant("editor", "wiki.page.edit");
|
|
134
|
+
asUser(uid);
|
|
135
|
+
await expect(assertPermission("wiki.page.edit")).resolves.toBeUndefined();
|
|
136
|
+
dbo.prepare("DELETE FROM sys_role_permission WHERE role_name = 'editor'").run();
|
|
137
|
+
vi.setSystemTime(Date.now() + 11_000);
|
|
138
|
+
await expect(assertPermission("wiki.page.edit")).rejects.toMatchObject({ status: 403 });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("禁用与未认证", () => {
|
|
143
|
+
it("is_forbidden=1 即拒(持 superadmin 也不例外)", async () => {
|
|
144
|
+
const uid = mkUser("u10", { forbidden: true });
|
|
145
|
+
bindRole(uid, "superadmin");
|
|
146
|
+
asUser(uid);
|
|
147
|
+
await expect(assertPermission("wiki.page.edit")).rejects.toMatchObject({ status: 403 });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("未认证(无 http.user)即拒", async () => {
|
|
151
|
+
asUser(undefined);
|
|
152
|
+
await expect(assertPermission("wiki.page.edit")).rejects.toMatchObject({ status: 403 });
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("AC1-9:系统管理员断言(D11)", () => {
|
|
157
|
+
it("assertSystemAdmin:admin 通过、editor 403、superadmin 通过", async () => {
|
|
158
|
+
const admin = mkUser("u11a");
|
|
159
|
+
bindRole(admin, "admin");
|
|
160
|
+
asUser(admin);
|
|
161
|
+
await expect(assertSystemAdmin()).resolves.toBeUndefined();
|
|
162
|
+
|
|
163
|
+
const editor = mkUser("u11b");
|
|
164
|
+
bindRole(editor, "editor");
|
|
165
|
+
asUser(editor);
|
|
166
|
+
await expect(assertSystemAdmin()).rejects.toMatchObject({ status: 403 });
|
|
167
|
+
|
|
168
|
+
const sup = mkUser("u11c");
|
|
169
|
+
bindRole(sup, "superadmin");
|
|
170
|
+
asUser(sup);
|
|
171
|
+
await expect(assertSystemAdmin()).resolves.toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("assertSuperAdmin:admin 也 403(安全敏感项边界,D11)", async () => {
|
|
175
|
+
const admin = mkUser("u12a");
|
|
176
|
+
bindRole(admin, "admin");
|
|
177
|
+
asUser(admin);
|
|
178
|
+
await expect(assertSuperAdmin()).rejects.toMatchObject({ status: 403 });
|
|
179
|
+
|
|
180
|
+
const sup = mkUser("u12b");
|
|
181
|
+
bindRole(sup, "superadmin");
|
|
182
|
+
asUser(sup);
|
|
183
|
+
await expect(assertSuperAdmin()).resolves.toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("断言出口同样吃 is_forbidden(禁用超管立即全拒)", async () => {
|
|
187
|
+
const sup = mkUser("u13", { forbidden: true });
|
|
188
|
+
bindRole(sup, "superadmin");
|
|
189
|
+
asUser(sup);
|
|
190
|
+
await expect(assertSuperAdmin()).rejects.toMatchObject({ status: 403 });
|
|
191
|
+
});
|
|
192
|
+
});
|