fix(files,security): S-1 per-source Move authorization, S-2 session-only annotation, S-5 restore guards
S-1 (/files/move, /files/organize action=move): - organize-guard: authorizeMoveEntities checks every source's media entities (declared entity plus entities of registered assets under the source); one-time approval still covers the whole request; any source that cannot be attributed to an entity only passes with an approval; denial lists every unauthorized item; optional non-consuming pre-check - transfer: entities per source (directory = registered assets beneath it) - organize: all move groups pre-checked before any group executes; any denial -> whole request 403 approval_required S-2: - api.ts: GET /files/download and POST /files/trash/purge marked auth:"session"; files routes attach requireAdminSession from the registry annotation; handler-side purge check removed, download keeps a same-rule defense check; permanent delete uses isAdminSession - DEV key rule written down: equals an admin-scope key for session-only endpoints, still admin for Move/delete approval bypass (as authorizeMove) - api-registry.test: session list updated; new reverse assertion that no non-session endpoint answers 403 to an admin-scope bearer S-5 (trash restore): - restore-to requires a writable managed library root (isWritableTarget) - in-place restore rebuilds missing parents level by level without following symlinks (mkdirWithin), realpath-checked per level - cross-volume restore capped by FILES_TRASH_CROSS_MAX_BYTES; cross-volume copies (trash in / restore out) go through fsproxy Tests: multi-entity move 403 + listing, unattributed source, organize foreign asset 403, download/purge 403 for DEV and admin-scope keys, restore target not writable, symlinked parent, rebuild parents, cross-volume cap. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9887d8b311
commit
17cf3cbf97
16 files changed
+587
-107
No files matched your search
@@ -2,7 +2,14 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { capabilitySpec, DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
|
||||
import {
|
||||
CSRF_HEADER,
|
||||
capabilitySpec,
|
||||
DEFAULT_SETTINGS,
|
||||
GlobalSettings,
|
||||
SESSION_COOKIE,
|
||||
type SettingsKey,
|
||||
} from "@umt/contracts";
|
||||
import { type DB, migrateToLatest } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
import { Kysely, PostgresDialect, sql } from "kysely";
|
||||
@@ -10,7 +17,7 @@ import pg from "pg";
|
||||
import { ConfigSchema } from "../../src/config.ts";
|
||||
import { createOutboxPort } from "../../src/context.ts";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { hashApiKey } from "../../src/http/auth.ts";
|
||||
import { deriveCsrfToken, hashApiKey, hashSessionToken } from "../../src/http/auth.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createLogger } from "../../src/logger.ts";
|
||||
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
@@ -25,9 +32,14 @@ export const DB_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:
|
||||
export const DEV_KEY = "dev-admin-fm-test-key";
|
||||
export const READ_KEY = "fm-organize-read-key";
|
||||
export const EXEC_KEY = "fm-organize-exec-key";
|
||||
export const ADMIN_KEY = "fm-admin-scope-key";
|
||||
/** 传给 api()/raw() 的 key:改用管理员控制台会话 cookie(+CSRF),而不是 Bearer */
|
||||
export const SESSION = "__admin_session__";
|
||||
const SESSION_TOKEN = "fm-test-admin-session-token-0123456789";
|
||||
const KEYS = [
|
||||
{ key: READ_KEY, scopes: ["organize.read"] },
|
||||
{ key: EXEC_KEY, scopes: ["organize.execute"] },
|
||||
{ key: ADMIN_KEY, scopes: ["admin"] },
|
||||
];
|
||||
|
||||
export async function pgAvailable() {
|
||||
@@ -188,8 +200,24 @@ export async function createFmHarness(
|
||||
return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null;
|
||||
},
|
||||
async touch() {},
|
||||
async findSession(hash) {
|
||||
if (hash !== hashSessionToken(SESSION_TOKEN)) return null;
|
||||
const now = new Date();
|
||||
return {
|
||||
id: "00000000-0000-4000-8000-00000000a001",
|
||||
adminUserId: "00000000-0000-4000-8000-00000000a002",
|
||||
username: "admin",
|
||||
expiresAt: new Date(now.getTime() + 3600_000),
|
||||
revokedAt: null,
|
||||
lastSeenAt: now,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const authHeaders = (key: string): Record<string, string> =>
|
||||
key === SESSION
|
||||
? { cookie: `${SESSION_COOKIE}=${SESSION_TOKEN}`, [CSRF_HEADER]: deriveCsrfToken(SESSION_TOKEN) }
|
||||
: { authorization: `Bearer ${key}` };
|
||||
|
||||
const root = await db
|
||||
.insertInto("library_root")
|
||||
@@ -219,7 +247,7 @@ export async function createFmHarness(
|
||||
const res = await app.request(`/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${key}`,
|
||||
...authHeaders(key),
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
@@ -227,7 +255,7 @@ export async function createFmHarness(
|
||||
return { status: res.status, body: await res.json() };
|
||||
},
|
||||
raw(method, path, key = DEV_KEY) {
|
||||
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: { authorization: `Bearer ${key}` } }));
|
||||
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: authHeaders(key) }));
|
||||
},
|
||||
async close() {
|
||||
await db.destroy();
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { appendFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createFmHarness, type FmHarness, key, pgAvailable, READ_KEY, seedAsset, seedEntity } from "./harness.ts";
|
||||
import {
|
||||
createFmHarness,
|
||||
EXEC_KEY,
|
||||
type FmHarness,
|
||||
key,
|
||||
pgAvailable,
|
||||
READ_KEY,
|
||||
seedAsset,
|
||||
seedEntity,
|
||||
} from "./harness.ts";
|
||||
|
||||
// identify(只读,本地 + 远端召回前 5,不落库)、organize(按作品分组走 planImport,dryRun→execute 一致与源变更)、
|
||||
// probe(已登记写回 media_info)、scrape(仅已登记作品,入队 refreshMetadata;NFO 待 W5)。
|
||||
@@ -114,6 +123,42 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
|
||||
expect(rec).toEqual({ trigger: "manual", action: "copy", media_entity_id: film });
|
||||
});
|
||||
|
||||
it("organize Move(验收 07 S-1):源是别的作品的已登记资产 → 只授权声明作品不够,整体 403 并列出", async () => {
|
||||
const other = await seedEntity(h.db, { kind: "movie", title: "Other Film", year: 2020 });
|
||||
await mkdir(join(h.lib, "Stolen"), { recursive: true });
|
||||
await writeFile(join(h.lib, "Stolen", "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "someone-else");
|
||||
await seedAsset(h.db, h, { entityId: other, rel: "Stolen/Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" });
|
||||
const grant = await h.db
|
||||
.insertInto("automation_grant")
|
||||
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [film] }), granted_by: "test" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const items = [{ root: h.libRoot, path: "Stolen/Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }];
|
||||
const dry = await h.api(
|
||||
"POST",
|
||||
"/files/organize",
|
||||
{ idempotencyKey: key(), items, action: "move", dryRun: true },
|
||||
EXEC_KEY,
|
||||
);
|
||||
expect(dry.status).toBe(200);
|
||||
const r = await h.api(
|
||||
"POST",
|
||||
"/files/organize",
|
||||
{ idempotencyKey: key(), items, action: "move", dryRun: false, planIds: [dry.body.data.groups[0].planId] },
|
||||
EXEC_KEY,
|
||||
);
|
||||
expect(r.status).toBe(403);
|
||||
expect(r.body.error.message).toMatch(/^approval_required:/);
|
||||
expect(r.body.error.message).toContain(other);
|
||||
const recs = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select("id")
|
||||
.where("source_path", "like", "%Stolen%")
|
||||
.execute();
|
||||
expect(recs).toEqual([]);
|
||||
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", grant.id).execute();
|
||||
});
|
||||
|
||||
it("organize:路径逃逸同样拒绝", async () => {
|
||||
const r = await h.api("POST", "/files/organize", {
|
||||
idempotencyKey: key(),
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { findCrossDeviceBase } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADMIN_KEY,
|
||||
createFmHarness,
|
||||
EXEC_KEY,
|
||||
type FmHarness,
|
||||
key,
|
||||
pgAvailable,
|
||||
SESSION,
|
||||
seedAsset,
|
||||
seedBinding,
|
||||
seedDownload,
|
||||
@@ -155,7 +158,10 @@ describe.skipIf(skip)("files 删除与回收站", () => {
|
||||
.execute();
|
||||
const denied = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, EXEC_KEY);
|
||||
expect(denied.status).toBe(403);
|
||||
const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() });
|
||||
// 验收 07 S-2:DEV key 与 admin scope Key 同样 403,只认管理员会话
|
||||
expect((await h.api("POST", "/files/trash/purge", { idempotencyKey: key() })).status).toBe(403);
|
||||
expect((await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, ADMIN_KEY)).status).toBe(403);
|
||||
const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, SESSION);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.purged).toEqual([oldId]);
|
||||
expect(await exists(a.body.data.trashed[0].trashPath)).toBe(false);
|
||||
@@ -212,3 +218,106 @@ describe.skipIf(skip)("files 删除与回收站", () => {
|
||||
expect(await exists(join(h.lib, "keep.txt"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// 验收 07 S-5:还原到… 的目标根可写检查;原位还原逐级建目录不跟随符号链接;跨卷还原受上限、经 fsproxy。
|
||||
const xdev = findCrossDeviceBase();
|
||||
describe.skipIf(skip)("files 回收站还原守卫", () => {
|
||||
let h: FmHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createFmHarness("trash5", { options: { trashCrossMaxBytes: 10 } });
|
||||
});
|
||||
afterAll(async () => h?.close());
|
||||
|
||||
const trashOne = async (rel: string, content: string) => {
|
||||
await mkdir(join(h.lib, dirname(rel)), { recursive: true });
|
||||
await writeFile(join(h.lib, rel), content);
|
||||
const d = await h.api("POST", "/files/delete", {
|
||||
idempotencyKey: key(),
|
||||
items: [{ root: h.libRoot, path: rel }],
|
||||
dryRun: false,
|
||||
});
|
||||
expect(d.status).toBe(200);
|
||||
return d.body.data.trashed[0].id as string;
|
||||
};
|
||||
|
||||
it("还原到下载根 / register_only 库根 → 403 not_writable,回收站条目不动", async () => {
|
||||
const id = await trashOne("ro/a.txt", "a");
|
||||
await mkdir(join(h.dl, "in"), { recursive: true });
|
||||
const toDl = await h.api("POST", "/files/trash/restore", {
|
||||
idempotencyKey: key(),
|
||||
ids: [id],
|
||||
target: { root: h.dlRoot, path: "in" },
|
||||
});
|
||||
expect(toDl.status).toBe(403);
|
||||
expect(toDl.body.error.message).toMatch(/^not_writable:/);
|
||||
|
||||
const roDir = await mkdtemp(join(h.base, "ro-lib-"));
|
||||
await writeFile(join(roDir, ".uhd-root"), "sentinel\n");
|
||||
const ro = await h.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "只登记库", media_kind: "mixed", local_path: roDir, management_mode: "register_only" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const toRo = await h.api("POST", "/files/trash/restore", {
|
||||
idempotencyKey: key(),
|
||||
ids: [id],
|
||||
target: { root: `lib:${ro.id}`, path: "" },
|
||||
});
|
||||
expect(toRo.status).toBe(403);
|
||||
expect(await exists(join(roDir, "a.txt"))).toBe(false);
|
||||
const left = await h.db.selectFrom("trash_item").select("id").where("id", "=", id).execute();
|
||||
expect(left).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("原位还原:父目录被换成指向根外的符号链接 → path_escape,根外不会被建出目录", async () => {
|
||||
const id = await trashOne("Esc/sub/file.txt", "f");
|
||||
await rm(join(h.lib, "Esc"), { recursive: true, force: true });
|
||||
await symlink(h.outside, join(h.lib, "Esc"));
|
||||
const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.restored).toEqual([]);
|
||||
expect(r.body.data.failed).toEqual([expect.objectContaining({ id, code: "path_escape" })]);
|
||||
expect(await exists(join(h.outside, "sub"))).toBe(false);
|
||||
await rm(join(h.lib, "Esc"));
|
||||
});
|
||||
|
||||
it("原位还原:父目录不存在时在根内逐级重建", async () => {
|
||||
const id = await trashOne("Deep/er/x.txt", "x");
|
||||
await rm(join(h.lib, "Deep"), { recursive: true, force: true });
|
||||
const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] });
|
||||
expect(r.body.data.restored).toEqual([{ id, root: h.libRoot, path: "Deep/er/x.txt" }]);
|
||||
expect(await readFile(join(h.lib, "Deep", "er", "x.txt"), "utf8")).toBe("x");
|
||||
});
|
||||
|
||||
it.skipIf(!xdev)(
|
||||
"跨卷还原:超过 FILES_TRASH_CROSS_MAX_BYTES → too_large 不动;小文件经 fsproxy 复制后核验",
|
||||
async () => {
|
||||
const xlib = await mkdtemp(join(xdev!, "fm-xrestore-"));
|
||||
try {
|
||||
await writeFile(join(xlib, ".uhd-root"), "sentinel\n");
|
||||
const x = await h.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "跨卷库", media_kind: "mixed", local_path: xlib, management_mode: "managed" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const big = await trashOne("xv/big.txt", "0123456789ABCDEF");
|
||||
const small = await trashOne("xv/small.txt", "abc");
|
||||
const r = await h.api("POST", "/files/trash/restore", {
|
||||
idempotencyKey: key(),
|
||||
ids: [big, small],
|
||||
target: { root: `lib:${x.id}`, path: "" },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.failed).toEqual([expect.objectContaining({ id: big, code: "too_large" })]);
|
||||
expect(r.body.data.restored).toEqual([{ id: small, root: `lib:${x.id}`, path: "small.txt" }]);
|
||||
expect(await readFile(join(xlib, "small.txt"), "utf8")).toBe("abc");
|
||||
expect(await exists(join(xlib, "big.txt"))).toBe(false);
|
||||
const left = await h.db.selectFrom("trash_item").select("id").execute();
|
||||
expect(left.map((l) => l.id)).toContain(big);
|
||||
expect(left.map((l) => l.id)).not.toContain(small);
|
||||
} finally {
|
||||
await rm(xlib, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -6,12 +6,14 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { fsErrorToFiles } from "../../src/modules/file-manager/common.ts";
|
||||
import {
|
||||
ADMIN_KEY,
|
||||
createFmHarness,
|
||||
EXEC_KEY,
|
||||
type FmHarness,
|
||||
key,
|
||||
pgAvailable,
|
||||
READ_KEY,
|
||||
SESSION,
|
||||
seedAsset,
|
||||
seedBinding,
|
||||
seedEntity,
|
||||
@@ -328,13 +330,18 @@ describe.skipIf(skip)("files W5-A2", () => {
|
||||
target_path: "gone",
|
||||
});
|
||||
|
||||
const perm = await h.api("POST", "/files/delete", {
|
||||
idempotencyKey: key(),
|
||||
items: [{ root: h.libRoot, path: "gone/x.srt" }],
|
||||
mode: "permanent",
|
||||
confirmName: "x.srt",
|
||||
dryRun: false,
|
||||
});
|
||||
const perm = await h.api(
|
||||
"POST",
|
||||
"/files/delete",
|
||||
{
|
||||
idempotencyKey: key(),
|
||||
items: [{ root: h.libRoot, path: "gone/x.srt" }],
|
||||
mode: "permanent",
|
||||
confirmName: "x.srt",
|
||||
dryRun: false,
|
||||
},
|
||||
SESSION,
|
||||
);
|
||||
expect(perm.status).toBe(200);
|
||||
const prec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
@@ -357,29 +364,32 @@ describe.skipIf(skip)("files W5-A2", () => {
|
||||
const url = (path: string, extra = "") =>
|
||||
`/files/download?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent(path)}${extra}`;
|
||||
|
||||
const ok = await h.raw("GET", url("dl-test/海报.jpg", "&inline=true"));
|
||||
const ok = await h.raw("GET", url("dl-test/海报.jpg", "&inline=true"), SESSION);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.text()).toBe("JPEGDATA");
|
||||
expect(ok.headers.get("content-type")).toBe("image/jpeg");
|
||||
expect(ok.headers.get("content-disposition")).toMatch(/^inline; .*filename\*=UTF-8''%E6%B5%B7%E6%8A%A5\.jpg/);
|
||||
expect(ok.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
const nfo = await h.raw("GET", url("dl-test/movie.nfo", "&inline=true"));
|
||||
const nfo = await h.raw("GET", url("dl-test/movie.nfo", "&inline=true"), SESSION);
|
||||
expect(nfo.headers.get("content-disposition")).toMatch(/^attachment;/);
|
||||
expect(await nfo.text()).toBe("<movie/>");
|
||||
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, READ_KEY)).status).toBe(403);
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, EXEC_KEY)).status).toBe(403);
|
||||
const video = await h.api("GET", url("dl-test/v.mkv"));
|
||||
// 验收 07 S-2:admin scope 的 Key 与 DEV key 同样 403(注册表 auth:"session",中间件执行)
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, ADMIN_KEY)).status).toBe(403);
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"))).status).toBe(403);
|
||||
const video = await h.api("GET", url("dl-test/v.mkv"), undefined, SESSION);
|
||||
expect(video.status).toBe(403);
|
||||
const big = await h.api("GET", url("dl-test/big.srt"));
|
||||
const big = await h.api("GET", url("dl-test/big.srt"), undefined, SESSION);
|
||||
expect(big.status).toBe(413);
|
||||
expect(big.body.error.message).toMatch(/^too_large:/);
|
||||
const leak = await h.api("GET", url("dl-test/leak.nfo"));
|
||||
const leak = await h.api("GET", url("dl-test/leak.nfo"), undefined, SESSION);
|
||||
expect(leak.status).toBe(400);
|
||||
expect(leak.body.error.message).toMatch(/^path_escape:/);
|
||||
expect((await h.api("GET", url("dl-test"))).status).toBe(400);
|
||||
expect((await h.api("GET", url("dl-test/none.nfo"))).status).toBe(404);
|
||||
expect((await h.api("GET", url("../outside/secret.txt"))).status).toBeGreaterThanOrEqual(400);
|
||||
expect((await h.api("GET", url("dl-test"), undefined, SESSION)).status).toBe(400);
|
||||
expect((await h.api("GET", url("dl-test/none.nfo"), undefined, SESSION)).status).toBe(404);
|
||||
expect((await h.api("GET", url("../outside/secret.txt"), undefined, SESSION)).status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
const audits = await h.db
|
||||
.selectFrom("audit_event")
|
||||
|
||||
@@ -348,4 +348,80 @@ describe.skipIf(skip)("files 写操作", () => {
|
||||
expect(await readFile(join(h.lib, "A", "CopyMe.mkv"), "utf8")).toBe("copy");
|
||||
expect(await exists(join(h.dl, "CopyMe.mkv"))).toBe(true);
|
||||
});
|
||||
it("多源 Move(验收 07 S-1):两个作品只授权一个 → 整体 403 并列出未授权项;都授权后放行;未归属作品的源只认审批", async () => {
|
||||
const e1 = await seedEntity(h.db, { kind: "movie", title: "授权作品", year: 2011 });
|
||||
const e2 = await seedEntity(h.db, { kind: "movie", title: "未授权作品", year: 2012 });
|
||||
await mkdir(join(h.lib, "S1"));
|
||||
await mkdir(join(h.lib, "S1dst"));
|
||||
await mkdir(join(h.lib, "S1", "Two"));
|
||||
await writeFile(join(h.lib, "S1", "One.2011.mkv"), "one");
|
||||
await writeFile(join(h.lib, "S1", "Two", "Two.2012.mkv"), "two");
|
||||
await seedAsset(h.db, h, { entityId: e1, rel: "S1/One.2011.mkv" });
|
||||
await seedAsset(h.db, h, { entityId: e2, rel: "S1/Two/Two.2012.mkv" });
|
||||
const g1 = await h.db
|
||||
.insertInto("automation_grant")
|
||||
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [e1] }), granted_by: "test" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const body = {
|
||||
items: [
|
||||
{ root: h.libRoot, path: "S1/One.2011.mkv" },
|
||||
{ root: h.libRoot, path: "S1/Two" },
|
||||
],
|
||||
target: { root: h.libRoot, path: "S1dst" },
|
||||
mode: "move",
|
||||
};
|
||||
const run = async () => {
|
||||
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
|
||||
expect(dry.status).toBe(200);
|
||||
return h.api(
|
||||
"POST",
|
||||
"/files/move",
|
||||
{ ...body, idempotencyKey: key(), dryRun: false, planId: dry.body.data.planId },
|
||||
EXEC_KEY,
|
||||
);
|
||||
};
|
||||
const denied = await run();
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body.error.message).toMatch(/^approval_required:/);
|
||||
expect(denied.body.error.message).toContain("S1/Two");
|
||||
expect(denied.body.error.message).toContain(e2);
|
||||
expect(denied.body.error.message).not.toContain("S1/One.2011.mkv(");
|
||||
// 整体拒绝:两个源都没动
|
||||
expect(await exists(join(h.lib, "S1", "One.2011.mkv"))).toBe(true);
|
||||
expect(await exists(join(h.lib, "S1", "Two", "Two.2012.mkv"))).toBe(true);
|
||||
const audit = await h.db
|
||||
.selectFrom("audit_event")
|
||||
.select(["payload"])
|
||||
.where("action", "=", "files.move_denied")
|
||||
.orderBy("at", "desc")
|
||||
.executeTakeFirst();
|
||||
expect(JSON.stringify(audit?.payload)).toContain(e2);
|
||||
|
||||
// 未登记文件(无法归属作品)混入:即使其余都有授权也拒绝
|
||||
await writeFile(join(h.lib, "S1", "Loose.mkv"), "loose");
|
||||
const g2 = await h.db
|
||||
.insertInto("automation_grant")
|
||||
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [e2] }), granted_by: "test" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const loose = { ...body, items: [...body.items, { root: h.libRoot, path: "S1/Loose.mkv" }] };
|
||||
const ldry = await h.api("POST", "/files/move", { ...loose, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
|
||||
const ldenied = await h.api(
|
||||
"POST",
|
||||
"/files/move",
|
||||
{ ...loose, idempotencyKey: key(), dryRun: false, planId: ldry.body.data.planId },
|
||||
EXEC_KEY,
|
||||
);
|
||||
expect(ldenied.status).toBe(403);
|
||||
expect(ldenied.body.error.message).toContain("S1/Loose.mkv");
|
||||
expect(ldenied.body.error.message).toContain("一次性审批");
|
||||
|
||||
// 两个作品都被覆盖 → 放行
|
||||
const ok = await run();
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await exists(join(h.lib, "S1dst", "One.2011.mkv"))).toBe(true);
|
||||
expect(await exists(join(h.lib, "S1dst", "Two", "Two.2012.mkv"))).toBe(true);
|
||||
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "in", [g1.id, g2.id]).execute();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user