forked from bai/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 文件路径安全单测(Metis 安全红线:穿越/.git/分隔符/深度)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateFilePath, FILE_PATH_MAX_DEPTH, FILE_PATH_MAX_LENGTH } from "../../src/database/filelib/fileService.js";
|
||||
import { FileLibError } from "../../src/database/filelib/model.js";
|
||||
|
||||
describe("validateFilePath · 路径安全", () => {
|
||||
it("正常相对路径通过(含 CJK 与多级)", () => {
|
||||
expect(validateFilePath("a.md")).toBe("a.md");
|
||||
expect(validateFilePath("docs/讲义/第一课.md")).toBe("docs/讲义/第一课.md");
|
||||
expect(validateFilePath("a/b/c/d.txt")).toBe("a/b/c/d.txt");
|
||||
});
|
||||
|
||||
it("拒绝穿越:.. 段、绝对路径", () => {
|
||||
expect(() => validateFilePath("../x")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a/../../x")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("/etc/passwd")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("拒绝 .git 段与点段", () => {
|
||||
expect(() => validateFilePath(".git")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a/.git/config")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("./a.md")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("拒绝反斜杠、控制字符、空段(双斜杠/尾斜杠)", () => {
|
||||
expect(() => validateFilePath("a\\b.md")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a\nb.md")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a//b.md")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a/b/")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("拒绝空路径、超长、超深", () => {
|
||||
expect(() => validateFilePath("")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a".repeat(FILE_PATH_MAX_LENGTH + 1))).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath(Array(FILE_PATH_MAX_DEPTH + 1).fill("d").join("/"))).toThrowError(FileLibError);
|
||||
expect(validateFilePath(Array(FILE_PATH_MAX_DEPTH).fill("d").join("/"))).toContain("d/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 命名规则(D14)单测。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FileLibError, nameKey, normalizeNodeName, NODE_NAME_MAX_LENGTH } from "../../src/database/filelib/model.js";
|
||||
|
||||
describe("normalizeNodeName · D14", () => {
|
||||
it("trim 空白", () => {
|
||||
expect(normalizeNodeName(" 物理必修一 ")).toBe("物理必修一");
|
||||
});
|
||||
|
||||
it("NFC 归一化(分解形式 → 合成形式)", () => {
|
||||
expect(normalizeNodeName("é")).toBe("é");
|
||||
});
|
||||
|
||||
it("CJK 与常见字符原样通过", () => {
|
||||
expect(normalizeNodeName("TH-141 表面张力 (上)")).toBe("TH-141 表面张力 (上)");
|
||||
});
|
||||
|
||||
it("空名/全空白 → invalid_name", () => {
|
||||
expect(() => normalizeNodeName("")).toThrowError(FileLibError);
|
||||
expect(() => normalizeNodeName(" ")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("超长 → invalid_name", () => {
|
||||
expect(() => normalizeNodeName("a".repeat(NODE_NAME_MAX_LENGTH + 1))).toThrowError(FileLibError);
|
||||
expect(normalizeNodeName("a".repeat(NODE_NAME_MAX_LENGTH))).toHaveLength(NODE_NAME_MAX_LENGTH);
|
||||
});
|
||||
|
||||
it("拒绝斜杠与反斜杠", () => {
|
||||
expect(() => normalizeNodeName("a/b")).toThrowError(FileLibError);
|
||||
expect(() => normalizeNodeName("a\\b")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("拒绝控制字符", () => {
|
||||
expect(() => normalizeNodeName("a\nb")).toThrowError(FileLibError);
|
||||
expect(() => normalizeNodeName("a\tb")).toThrowError(FileLibError);
|
||||
expect(() => normalizeNodeName("a\0b")).toThrowError(FileLibError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nameKey · 大小写不敏感比较键", () => {
|
||||
it("ASCII 折叠", () => {
|
||||
expect(nameKey("Physics")).toBe("physics");
|
||||
});
|
||||
it("CJK 不变", () => {
|
||||
expect(nameKey("物理")).toBe("物理");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 纯权限 reducer 单测(契约 P6 / D11 / 2.3)。
|
||||
* 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限/toggle 冻结;
|
||||
* 外加确定性随机化不变量(单调性:任何可用 grant 都不超过 effective)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkAccess, effectiveRole, type EffectiveRoleInput, type FileLibGrantFact } from "../../src/database/filelib/permission.js";
|
||||
|
||||
const base: EffectiveRoleInput = {
|
||||
nodeId: "N",
|
||||
nodeKind: "FOLDER",
|
||||
ancestorIds: ["A", "R"], // N ⊂ A ⊂ R
|
||||
independentPermissionsEnabled: false,
|
||||
userId: "u1",
|
||||
groupIds: ["g1"],
|
||||
grants: [],
|
||||
};
|
||||
|
||||
function grant(partial: Partial<FileLibGrantFact>): FileLibGrantFact {
|
||||
return {
|
||||
nodeId: "N",
|
||||
principalType: "USER",
|
||||
principalId: "u1",
|
||||
role: "VIEW",
|
||||
isCreatorGrant: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("effectiveRole · 契约 P6 矩阵", () => {
|
||||
it("无任何 grant → null(无权限)", () => {
|
||||
expect(effectiveRole(base)).toBeNull();
|
||||
});
|
||||
|
||||
it("个人直接 grant 在 self 上 → 生效", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ role: "EDIT" })] })).toBe("EDIT");
|
||||
});
|
||||
|
||||
it("Group grant 且用户在组内 → 生效", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ principalType: "GROUP", principalId: "g1", role: "MANAGE" })] })).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("Group grant 但用户不在组内 → null", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ principalType: "GROUP", principalId: "g9" })] })).toBeNull();
|
||||
});
|
||||
|
||||
it("祖先链上的 grant 向下继承", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ nodeId: "A", role: "EDIT" })] })).toBe("EDIT");
|
||||
expect(effectiveRole({ ...base, grants: [grant({ nodeId: "R", role: "VIEW" })] })).toBe("VIEW");
|
||||
});
|
||||
|
||||
it("取最高:个人低权限 + Group 高权限 → 高权限(个人不能降权)", () => {
|
||||
const grants = [
|
||||
grant({ role: "VIEW" }),
|
||||
grant({ principalType: "GROUP", principalId: "g1", role: "MANAGE" }),
|
||||
];
|
||||
expect(effectiveRole({ ...base, grants })).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("取最高:链上多处 grant,最高者胜", () => {
|
||||
const grants = [
|
||||
grant({ nodeId: "R", role: "MANAGE" }),
|
||||
grant({ nodeId: "A", role: "VIEW" }),
|
||||
grant({ nodeId: "N", role: "EDIT" }),
|
||||
];
|
||||
expect(effectiveRole({ ...base, grants })).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("链外节点的 grant 不参与", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ nodeId: "X", role: "MANAGE" })] })).toBeNull();
|
||||
});
|
||||
|
||||
it("他人的个人 grant 不参与", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ principalId: "u2", role: "MANAGE" })] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveRole · D11 独立权限开关", () => {
|
||||
const project: EffectiveRoleInput = { ...base, nodeKind: "PROJECT", nodeId: "P" };
|
||||
|
||||
it("开关关闭:项目级非创建者 grant 冻结", () => {
|
||||
const grants = [grant({ nodeId: "P", role: "EDIT" })];
|
||||
expect(effectiveRole({ ...project, grants })).toBeNull();
|
||||
});
|
||||
|
||||
it("开关关闭:创建者 grant 仍生效", () => {
|
||||
const grants = [grant({ nodeId: "P", role: "MANAGE", isCreatorGrant: true })];
|
||||
expect(effectiveRole({ ...project, grants })).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("开关关闭:祖先链 grant 不受影响", () => {
|
||||
const grants = [
|
||||
grant({ nodeId: "P", role: "MANAGE" }), // 冻结
|
||||
grant({ nodeId: "A", role: "VIEW" }), // 生效
|
||||
];
|
||||
expect(effectiveRole({ ...project, grants })).toBe("VIEW");
|
||||
});
|
||||
|
||||
it("开关开启:项目级 grant 恢复参与", () => {
|
||||
const grants = [grant({ nodeId: "P", role: "EDIT" })];
|
||||
expect(effectiveRole({ ...project, independentPermissionsEnabled: true, grants })).toBe("EDIT");
|
||||
});
|
||||
|
||||
it("文件夹忽略开关(self grant 照常参与)", () => {
|
||||
const grants = [grant({ nodeId: "N", role: "EDIT" })];
|
||||
expect(effectiveRole({ ...base, grants })).toBe("EDIT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveRole · 随机化不变量", () => {
|
||||
// 确定性 LCG,替代 property-based 框架(不新增依赖)。
|
||||
function lcg(seed: number): () => number {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 1103515245 + 12345) % 2147483648;
|
||||
return s / 2147483648;
|
||||
};
|
||||
}
|
||||
const roles = ["VIEW", "EDIT", "MANAGE"] as const;
|
||||
|
||||
it("effective 恰为所有可用 grant 的最高秩(单调、顺序无关)", () => {
|
||||
const rand = lcg(42);
|
||||
for (let round = 0; round < 200; round += 1) {
|
||||
const nodes = ["N", "A", "R", "X"];
|
||||
const principals = ["u1", "u2", "g1", "g9"];
|
||||
const grants: FileLibGrantFact[] = Array.from({ length: Math.floor(rand() * 12) }, () => {
|
||||
const principalId = principals[Math.floor(rand() * principals.length)]!;
|
||||
return grant({
|
||||
nodeId: nodes[Math.floor(rand() * nodes.length)]!,
|
||||
principalType: principalId.startsWith("g") ? "GROUP" : "USER",
|
||||
principalId,
|
||||
role: roles[Math.floor(rand() * roles.length)]!,
|
||||
});
|
||||
});
|
||||
const input: EffectiveRoleInput = { ...base, grants };
|
||||
const applicableRanks = grants
|
||||
.filter((g) => ["N", "A", "R"].includes(g.nodeId))
|
||||
.filter((g) => (g.principalType === "USER" ? g.principalId === "u1" : g.principalId === "g1"))
|
||||
.map((g) => ({ VIEW: 1, EDIT: 2, MANAGE: 3 })[g.role]);
|
||||
const expected = applicableRanks.length === 0 ? 0 : Math.max(...applicableRanks);
|
||||
const actual = effectiveRole(input);
|
||||
expect(actual === null ? 0 : { VIEW: 1, EDIT: 2, MANAGE: 3 }[actual]).toBe(expected);
|
||||
// 顺序无关
|
||||
expect(effectiveRole({ ...input, grants: [...grants].reverse() })).toBe(actual);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAccess · D8 语义", () => {
|
||||
it("无权限 → not_found(404 不泄露)", () => {
|
||||
expect(checkAccess(null, "VIEW")).toEqual({ allowed: false, reason: "not_found" });
|
||||
});
|
||||
it("权限不足 → forbidden(403)", () => {
|
||||
expect(checkAccess("VIEW", "EDIT")).toEqual({ allowed: false, reason: "forbidden" });
|
||||
});
|
||||
it("权限足够 → allowed 并带回实际角色", () => {
|
||||
expect(checkAccess("MANAGE", "EDIT")).toEqual({ allowed: true, role: "MANAGE" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* InMemory VersionStore 语义单测(契约 C1:S1/S2/S7 + 计划 D16/S4)。
|
||||
* mock 必须如实表达:冲突走返回值、baseVersion=null 表新建、init 幂等、
|
||||
* 文件级版本互不影响、同仓库写操作串行。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
|
||||
import { FileLibError } from "../../src/database/filelib/model.js";
|
||||
|
||||
const DIR = "/repos/p1";
|
||||
|
||||
describe("InMemoryVersionStore · C1 语义", () => {
|
||||
it("init 幂等(S7);未 init 的仓库操作报 repo_not_found", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await expect(store.head(DIR, "a.md")).rejects.toThrowError(FileLibError);
|
||||
await store.init(DIR);
|
||||
await store.init(DIR); // 幂等,不报错、不重建
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "hello" });
|
||||
await store.init(DIR); // 已有内容后再 init 也不清空
|
||||
expect(await store.head(DIR, "a.md")).toBe("v1");
|
||||
});
|
||||
|
||||
it("新建(baseVersion null)→ ok;head/read 命中", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
const r = await store.commit(DIR, "a.md", { baseVersion: null, content: "v1 内容", author: "u1" });
|
||||
expect(r).toEqual({ status: "ok", version: "v1" });
|
||||
expect((await store.read(DIR, "a.md")).toString()).toBe("v1 内容");
|
||||
});
|
||||
|
||||
it("对已存在文件再次『新建』 → conflict(S2)", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
|
||||
const r = await store.commit(DIR, "a.md", { baseVersion: null, content: "2" });
|
||||
expect(r).toEqual({ status: "conflict", currentVersion: "v1" });
|
||||
});
|
||||
|
||||
it("baseVersion 落后于当前 → conflict 并带 currentVersion(S1)", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
|
||||
const ok = await store.commit(DIR, "a.md", { baseVersion: "v1", content: "2" });
|
||||
expect(ok.status).toBe("ok");
|
||||
const stale = await store.commit(DIR, "a.md", { baseVersion: "v1", content: "3" });
|
||||
expect(stale).toEqual({ status: "conflict", currentVersion: "v2" });
|
||||
});
|
||||
|
||||
it("正确 base 连续提交;旧版本仍可读", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "一" });
|
||||
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "二" });
|
||||
expect((await store.read(DIR, "a.md", "v1")).toString()).toBe("一");
|
||||
expect((await store.read(DIR, "a.md")).toString()).toBe("二");
|
||||
});
|
||||
|
||||
it("D16 文件级版本:文件 A 的提交不影响文件 B 的 base", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "A1" });
|
||||
const b1 = await store.commit(DIR, "b.md", { baseVersion: null, content: "B1" });
|
||||
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "A2" }); // A 前进到 v3
|
||||
// B 仍以自己的 head 为 base,不受 A 推进影响
|
||||
const b2 = await store.commit(DIR, "b.md", {
|
||||
baseVersion: b1.status === "ok" ? b1.version : "",
|
||||
content: "B2",
|
||||
});
|
||||
expect(b2.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("remove:正确 base → ok;head 随后 404;stale base → conflict", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
|
||||
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "2" });
|
||||
expect(await store.remove(DIR, "a.md", "v1")).toEqual({ status: "conflict", currentVersion: "v2" });
|
||||
expect((await store.remove(DIR, "a.md", "v2")).status).toBe("ok");
|
||||
await expect(store.head(DIR, "a.md")).rejects.toThrowError(FileLibError);
|
||||
await expect(store.remove(DIR, "a.md", "v3")).rejects.toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("history 新→旧,支持 limit;list 排除已删并按前缀过滤", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "docs/a.md", { baseVersion: null, content: "1", message: "初版" });
|
||||
await store.commit(DIR, "docs/a.md", { baseVersion: "v1", content: "2", message: "二版" });
|
||||
await store.commit(DIR, "other/b.md", { baseVersion: null, content: "x" });
|
||||
const h = await store.history(DIR, "docs/a.md");
|
||||
expect(h.map((v) => v.message)).toEqual(["二版", "初版"]);
|
||||
expect((await store.history(DIR, "docs/a.md", 1)).map((v) => v.message)).toEqual(["二版"]);
|
||||
expect((await store.list(DIR)).map((f) => f.path)).toEqual(["docs/a.md", "other/b.md"]);
|
||||
expect((await store.list(DIR, "docs/")).map((f) => f.path)).toEqual(["docs/a.md"]);
|
||||
await store.remove(DIR, "other/b.md", "v3");
|
||||
expect((await store.list(DIR)).map((f) => f.path)).toEqual(["docs/a.md"]);
|
||||
});
|
||||
|
||||
it("S4 同仓库写串行:并发同 base 提交,恰好一成一冲突", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
|
||||
const [r1, r2] = await Promise.all([
|
||||
store.commit(DIR, "a.md", { baseVersion: "v1", content: "x" }),
|
||||
store.commit(DIR, "a.md", { baseVersion: "v1", content: "y" }),
|
||||
]);
|
||||
const statuses = [r1.status, r2.status].sort();
|
||||
expect(statuses).toEqual(["conflict", "ok"]);
|
||||
});
|
||||
|
||||
it("diff 输出含增删行", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "keep\nold" });
|
||||
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "keep\nnew" });
|
||||
const d = await store.diff(DIR, "a.md", "v1", "v2");
|
||||
expect(d).toContain("-old");
|
||||
expect(d).toContain("+new");
|
||||
expect(d).toContain(" keep");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user