- 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
177 lines
6.9 KiB
TypeScript
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",
|
|
]),
|
|
);
|
|
});
|
|
});
|