import { createMockAlist, serveMockAlist } from "@umt/adapters/alist-mock"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildHealth } from "../../src/modules/system/health.ts"; import { clearStorageCache, storageEntry } from "../../src/storage/registry.ts"; import { DEV_KEY, json } from "../helpers.ts"; import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts"; // 存储实例(Alist / OpenList)设置接口:只写凭据、测试连接(版本 / 用户 / 根路径 / 容量)、 // 令牌失效 → offline、被库根引用不能删、健康检查里的存储实例(docs/design/storage-alist.md §3、§4)。 const hasPg = await pgAvailable(); describe.skipIf(!hasPg)("存储实例(真实 PG + 模拟 Alist HTTP)", () => { let h: Harness; let admin: ReturnType; const mock = createMockAlist({ token: "perm-token-xyz", username: "alice", password: "pw-alice", mountDetails: { "/s1": { total_space: 1000, free_space: 250, used_space: 750 } }, }); mock.mkdirp("/s1/media"); let srv: { base: string; close: () => Promise }; beforeAll(async () => { srv = serveMockAlist(mock); h = await createHarness({ engine: false }); admin = bearer(h.app, DEV_KEY); }, 60_000); afterAll(async () => { await h?.close(); await srv?.close(); }); it("新建(令牌):凭据只写不读;测试连接拿到版本、用户、根路径、容量", async () => { const c = await admin("POST", "/storage-instances", { name: "openlist-a", baseUrl: `${srv.base}/`, rootPath: "/s1/media/", authKind: "token", credential: "perm-token-xyz", pathPassword: "folder-pw", }); expect(c.status).toBe(200); const row = (await json(c)).data; expect(row.baseUrl).toBe(srv.base); expect(row.rootPath).toBe("/s1/media"); expect(row.secretRef).toBeTruthy(); expect(row.pathPasswordRef).toBeTruthy(); expect(JSON.stringify(row)).not.toContain("perm-token-xyz"); expect(JSON.stringify(row)).not.toContain("folder-pw"); expect(await h.ctx.secrets.resolve(row.secretRef)).toBe("perm-token-xyz"); const t = (await json(await admin("POST", `/storage-instances/${row.id}/test`))).data; expect(t).toMatchObject({ ok: true, healthStatus: "healthy", user: "alice", rootOk: true }); expect(t.version).toContain("v4"); expect(t.usage).toEqual({ total: 1000, free: 250, used: 750 }); const after = (await json(await admin("GET", `/storage-instances/${row.id}`))).data; expect(after.healthStatus).toBe("healthy"); }); it("账号密码:缺密码 422;改成错密码后测试 = offline 且信息里没有密码", async () => { const bad = await admin("POST", "/storage-instances", { name: "pw-missing", baseUrl: srv.base, authKind: "password", username: "alice", }); expect(bad.status).toBe(422); const c = ( await json( await admin("POST", "/storage-instances", { name: "openlist-pw", baseUrl: srv.base, authKind: "password", username: "alice", credential: "pw-alice", }), ) ).data; expect((await json(await admin("POST", `/storage-instances/${c.id}/test`))).data.ok).toBe(true); await admin("PATCH", `/storage-instances/${c.id}`, { credential: "wrong-secret-pw" }); const t = (await json(await admin("POST", `/storage-instances/${c.id}/test`))).data; expect(t).toMatchObject({ ok: false, healthStatus: "offline" }); expect(t.message).toContain("登录失败"); expect(t.message).not.toContain("wrong-secret-pw"); }); it("永久令牌失效:测试与健康检查都报不可用", async () => { const c = ( await json( await admin("POST", "/storage-instances", { name: "openlist-badtoken", baseUrl: srv.base, rootPath: "/s1", credential: "revoked-token", }), ) ).data; const t = (await json(await admin("POST", `/storage-instances/${c.id}/test`))).data; expect(t.ok).toBe(false); expect(t.message).toContain("令牌失效"); clearStorageCache(); const health = await buildHealth({ db: h.ctx.db, engineSchema: "openworkflow", dataDir: "/tmp", self: { version: "t", startedAt: new Date(), sseConnections: 0 }, thresholds: { warnPercent: 85, criticalPercent: 95 }, }); const m = health.mounts.find((x) => x.kind === "storage_instance" && x.id === c.id); expect(m).toMatchObject({ status: "offline", reason: "permission_denied" }); const ok = health.mounts.find((x) => x.kind === "storage_instance" && x.name === "openlist-a"); expect(ok?.status).toBe("online"); }); it("改凭据后注册表重建客户端;清除路径密码", async () => { const row = (await json(await admin("GET", "/storage-instances?limit=50"))).data.items.find( (x: { name: string }) => x.name === "openlist-a", ); const e1 = await storageEntry(row.id); expect(e1.secrets).toContain("folder-pw"); const r = (await json(await admin("PATCH", `/storage-instances/${row.id}`, { pathPassword: null }))).data; expect(r.pathPasswordRef).toBeNull(); const e2 = await storageEntry(row.id); expect(e2.client).not.toBe(e1.client); expect(e2.secrets).not.toContain("folder-pw"); }); it("被媒体库目录引用时不能删;解除后可删,凭据一并删除", async () => { const c = ( await json( await admin("POST", "/storage-instances", { name: "to-delete", baseUrl: srv.base, rootPath: "/s1", credential: "perm-token-xyz", }), ) ).data; const lib = await admin("POST", "/library-roots", { name: "Alist 电影", localPath: `/@alist/${c.id}/s1/media`, mediaKind: "movie", }); expect(lib.status).toBe(200); const libRow = (await json(lib)).data; expect(libRow.sentinelFile).toBeNull(); const missing = await admin("POST", "/library-roots", { name: "不存在的目录", localPath: `/@alist/${c.id}/s1/nope`, }); expect(missing.status).toBe(412); const blocked = await admin("DELETE", `/storage-instances/${c.id}`); expect(blocked.status).toBe(409); const test = (await json(await admin("POST", `/library-roots/${libRow.id}/test`))).data; expect(test.ok).toBe(true); await admin("DELETE", `/library-roots/${libRow.id}`); const del = await admin("DELETE", `/storage-instances/${c.id}`); expect(del.status).toBe(200); await expect(h.ctx.secrets.resolve(c.secretRef)).rejects.toThrow(); const audits = await h.ctx.db .selectFrom("audit_event") .select("action") .where("action", "like", "storage_instance.%") .execute(); expect(new Set(audits.map((a) => a.action))).toEqual( new Set([ "storage_instance.create", "storage_instance.update", "storage_instance.test", "storage_instance.delete", ]), ); }); });