217 lines
6.4 KiB
TypeScript
217 lines
6.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
|
import { executeContentExternalMutation } from "./mutation-runtime-external";
|
|
import type { ContentMutationFailure } from "./mutations";
|
|
|
|
const fsMocks = vi.hoisted(() => ({
|
|
mkdir: vi.fn(),
|
|
readFile: vi.fn(),
|
|
unlink: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
}));
|
|
|
|
const dbMocks = vi.hoisted(() => {
|
|
const onDuplicateKeyUpdate = vi.fn(async () => undefined);
|
|
const values = vi.fn(() => ({ onDuplicateKeyUpdate }));
|
|
const insert = vi.fn(() => ({ values }));
|
|
const where = vi.fn(async () => undefined);
|
|
const deleteFn = vi.fn(() => ({ where }));
|
|
return { deleteFn, insert, onDuplicateKeyUpdate, values, where };
|
|
});
|
|
const siteMocks = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
reload: vi.fn(),
|
|
update: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("node:fs/promises", () => fsMocks);
|
|
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
|
|
vi.mock("@/lib/db", () => ({
|
|
CameraWeb: {},
|
|
EmulatorSettings: {},
|
|
WebsiteSetting: {},
|
|
db: { delete: dbMocks.deleteFn, insert: dbMocks.insert },
|
|
}));
|
|
vi.mock("@/lib/services/rcon", () => ({
|
|
rcon: { updateConfig: vi.fn() },
|
|
}));
|
|
vi.mock("@/lib/services/site-settings", () => ({
|
|
siteSettings: siteMocks,
|
|
}));
|
|
vi.mock("@/lib/services/staff-activity", () => ({
|
|
logStaffActivity: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/admin/photo-files", () => ({
|
|
tryRemoveLocalPhotoFile: vi.fn(),
|
|
}));
|
|
|
|
const capability = {
|
|
actor: { id: 42, username: "operator", rank: 7 },
|
|
isSuperAdmin: false,
|
|
has: () => true,
|
|
hasAny: () => true,
|
|
hasAll: () => true,
|
|
} satisfies HousekeepingCapabilityContext;
|
|
const context = {
|
|
capability,
|
|
correlationId: "external-runtime",
|
|
legacy: false,
|
|
};
|
|
|
|
describe("Content external mutation runtime", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
siteMocks.get.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
|
|
const file = new File(
|
|
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
|
"photo.png",
|
|
{ type: "image/png" },
|
|
);
|
|
const snapshot = await executeContentExternalMutation(
|
|
"media.upload",
|
|
{ file },
|
|
context,
|
|
);
|
|
|
|
expect(snapshot).toMatchObject({
|
|
before: null,
|
|
after: { size: file.size, type: "image/png" },
|
|
output: { url: expect.stringMatching(/^\/api\/media\/.+[.]png$/u) },
|
|
});
|
|
expect(fsMocks.mkdir).toHaveBeenCalledTimes(1);
|
|
expect(fsMocks.writeFile).toHaveBeenCalledWith(
|
|
expect.stringContaining("storage"),
|
|
expect.any(Buffer),
|
|
);
|
|
});
|
|
|
|
it("propagates a real storage failure before optimistic success", async () => {
|
|
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline"));
|
|
const file = new File(
|
|
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
|
"photo.png",
|
|
{ type: "image/png" },
|
|
);
|
|
|
|
await expect(
|
|
executeContentExternalMutation("media.upload", { file }, context),
|
|
).rejects.toThrow("disk offline");
|
|
});
|
|
|
|
it("maps normalized media traversal to validation instead of dependency failure", async () => {
|
|
await expect(
|
|
executeContentExternalMutation(
|
|
"media.delete",
|
|
{ filename: "../private.txt" },
|
|
context,
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: "VALIDATION",
|
|
messageKey: "errors.housekeeping.validation",
|
|
} satisfies Partial<ContentMutationFailure>);
|
|
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects declared MIME, filename extension, and actual bytes that disagree", async () => {
|
|
const disguisedSvg = new File(
|
|
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
|
|
"photo.svg",
|
|
{ type: "image/png" },
|
|
);
|
|
|
|
await expect(
|
|
executeContentExternalMutation(
|
|
"media.upload",
|
|
{ file: disguisedSvg },
|
|
context,
|
|
),
|
|
).rejects.toMatchObject({ code: "VALIDATION" });
|
|
expect(fsMocks.writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an oversized logo before reading its bytes", async () => {
|
|
const arrayBuffer = vi.fn();
|
|
const file = {
|
|
name: "logo.png",
|
|
type: "image/png",
|
|
size: 5 * 1024 * 1024 + 1,
|
|
arrayBuffer,
|
|
};
|
|
|
|
await expect(
|
|
executeContentExternalMutation("logo.save", { file }, context),
|
|
).rejects.toMatchObject({ code: "VALIDATION" });
|
|
expect(arrayBuffer).not.toHaveBeenCalled();
|
|
expect(fsMocks.writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("removes a newly written favicon when the database write fails", async () => {
|
|
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
|
new Error("database offline"),
|
|
);
|
|
const file = new File(
|
|
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
|
"favicon.png",
|
|
{ type: "image/png" },
|
|
);
|
|
|
|
await expect(
|
|
executeContentExternalMutation("favicon.save", { file }, context),
|
|
).rejects.toThrow("database offline");
|
|
const writtenPath = fsMocks.writeFile.mock.calls[0]?.[0];
|
|
expect(fsMocks.unlink).toHaveBeenCalledWith(writtenPath);
|
|
});
|
|
|
|
it("reports a partial favicon result when database failure compensation also fails", async () => {
|
|
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
|
new Error("database offline"),
|
|
);
|
|
fsMocks.unlink.mockRejectedValueOnce(new Error("cleanup failed"));
|
|
const file = new File(
|
|
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
|
"favicon.png",
|
|
{ type: "image/png" },
|
|
);
|
|
|
|
await expect(
|
|
executeContentExternalMutation("favicon.save", { file }, context),
|
|
).rejects.toMatchObject({
|
|
name: "ContentCommittedExternalFailure",
|
|
snapshot: {
|
|
before: { value: null },
|
|
after: { value: null },
|
|
output: { compensation: "failed" },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("removes a newly written logo when the database write fails", async () => {
|
|
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
|
new Error("database offline"),
|
|
);
|
|
const file = new File(
|
|
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
|
"logo.png",
|
|
{ type: "image/png" },
|
|
);
|
|
await expect(
|
|
executeContentExternalMutation("logo.save", { file }, context),
|
|
).rejects.toThrow("database offline");
|
|
expect(fsMocks.unlink).toHaveBeenCalledWith(
|
|
fsMocks.writeFile.mock.calls[0]?.[0],
|
|
);
|
|
});
|
|
|
|
it("does not delete the existing favicon before the database delete commits", async () => {
|
|
siteMocks.get.mockResolvedValueOnce("/api/media/favicon/old.png");
|
|
dbMocks.where.mockRejectedValueOnce(new Error("database offline"));
|
|
await expect(
|
|
executeContentExternalMutation("favicon.delete", {}, context),
|
|
).rejects.toThrow("database offline");
|
|
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
|
});
|
|
});
|