Files
rootandClaude Opus 5.5 c2bf8a071a feat(storage): 存储实例与 Alist 虚拟路径(迁移 0090、设置接口、注册表、vfs、健康检查)
- storage_instance 表 + secret 用途 storage_credential;令牌 / 密码 / 路径密码只写不读
- /storage-instances CRUD 与测试连接(版本、用户、根路径、容量);被库根引用不能删
- 远端路径 /@alist/<id>/<路径>,vfs 统一本地 / Alist 的 stat、列目录、建目录、读写、移动、删除
- 整理矩阵中涉及 Alist 的传输(storage/transfer.ts)与远端 ffprobe(直链只读头)
- 媒体库目录可登记在 Alist 上(不写哨兵,目录可访问即在线);健康检查列出存储实例

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 23:52:20 +08:00

177 lines
6.9 KiB
TypeScript

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<typeof bearer>;
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<void> };
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",
]),
);
});
});