Add Catalog Studio inspection and guided import review
This commit is contained in:
1 parent
2578bf6a09
commit
26f071117b
7 files changed
+960
-43
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
read: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
guard: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (options: unknown, handler: unknown) => {
|
||||
mocks.guard(options);
|
||||
return handler;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
|
||||
vi.mock("@/lib/services/furni-data", () => ({ readFurniData: mocks.read }));
|
||||
vi.mock("@/lib/services/furni-asset-dirs", () => ({
|
||||
getFurniAssetDirs: async () => ({
|
||||
nitroDir: "/assets/nitro",
|
||||
iconDir: "/assets/icons",
|
||||
}),
|
||||
}));
|
||||
vi.mock("node:fs", () => ({ promises: { stat: mocks.stat } }));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
const request = (classnames: unknown) =>
|
||||
new NextRequest("http://localhost/api/admin/studio/inspect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ classnames }),
|
||||
});
|
||||
describe("furniture inspection", () => {
|
||||
beforeEach(() => {
|
||||
mocks.execute.mockReset().mockResolvedValue([[], []]);
|
||||
mocks.read.mockReset().mockResolvedValue({
|
||||
roomitemtypes: { furnitype: [] },
|
||||
wallitemtypes: { furnitype: [] },
|
||||
});
|
||||
mocks.stat.mockReset().mockRejectedValue({ code: "ENOENT" });
|
||||
});
|
||||
it("requires asset import permission", () => {
|
||||
expect(mocks.guard).toHaveBeenCalledWith({
|
||||
permission: expect.any(String),
|
||||
});
|
||||
});
|
||||
it("rejects path traversal before reading data", async () => {
|
||||
expect((await POST(request(["../secret"]))).status).toBe(400);
|
||||
expect(mocks.execute).not.toHaveBeenCalled();
|
||||
expect(mocks.stat).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses local SQL IDs and color-aware asset filenames", async () => {
|
||||
mocks.execute
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
{
|
||||
id: 90,
|
||||
spriteId: 91,
|
||||
classname: "chair*2",
|
||||
name: "Chair",
|
||||
type: "s",
|
||||
},
|
||||
],
|
||||
[],
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
[{ classname: "chair*2", id: 5, pageId: 7, credits: 3, points: 0 }],
|
||||
[],
|
||||
]);
|
||||
const response = await POST(request(["chair*2"]));
|
||||
const body = await response.json();
|
||||
expect(body.items[0].sql[0].id).toBe(90);
|
||||
expect(body.items[0].catalog[0].pageId).toBe(7);
|
||||
expect(
|
||||
mocks.stat.mock.calls.map((call) =>
|
||||
String(call[0]).replaceAll("\\", "/"),
|
||||
),
|
||||
).toEqual(["/assets/nitro/chair.nitro", "/assets/icons/chair_2_icon.png"]);
|
||||
});
|
||||
it("distinguishes unreadable data and denied assets from missing files", async () => {
|
||||
mocks.read.mockRejectedValue(new Error("unreadable"));
|
||||
mocks.stat.mockRejectedValue({ code: "EACCES" });
|
||||
const body = await (await POST(request(["chair"]))).json();
|
||||
expect(body.items[0].furnidataReadable).toBe(false);
|
||||
expect(body.items[0].icon.exists).toBeNull();
|
||||
expect(body.items[0].nitro.exists).toBeNull();
|
||||
});
|
||||
it("handles malformed furnidata without inventing a clean comparison", async () => {
|
||||
mocks.read.mockResolvedValue({
|
||||
roomitemtypes: { furnitype: {} },
|
||||
wallitemtypes: { furnitype: [null] },
|
||||
});
|
||||
const response = await POST(request(["chair"]));
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).items[0].furnidataReadable).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareFurniture,
|
||||
type FurnitureInspection,
|
||||
validateClassnames,
|
||||
} from "./studio-inspection";
|
||||
|
||||
const source = {
|
||||
name: "Chair",
|
||||
description: "A chair",
|
||||
revision: 2,
|
||||
type: "flooritem",
|
||||
};
|
||||
const empty: FurnitureInspection = {
|
||||
classname: "chair",
|
||||
sql: [],
|
||||
catalog: [],
|
||||
furnidata: [],
|
||||
furnidataReadable: true,
|
||||
nitro: { exists: false, bytes: 0 },
|
||||
icon: { exists: false, bytes: 0 },
|
||||
};
|
||||
describe("studio inspection", () => {
|
||||
it("rejects paths and oversized batches", () => {
|
||||
expect(validateClassnames(["../file"])).toBeNull();
|
||||
expect(validateClassnames(["a\\b"])).toBeNull();
|
||||
expect(validateClassnames(Array(501).fill("chair"))).toBeNull();
|
||||
expect(validateClassnames(["chair*0", "chair*0"])).toEqual(["chair*0"]);
|
||||
});
|
||||
it("identifies new furniture only after a successful lookup", () => {
|
||||
expect(compareFurniture(source, empty).state).toBe("new");
|
||||
expect(
|
||||
compareFurniture(source, { ...empty, furnidataReadable: false }).state,
|
||||
).toBe("unknown");
|
||||
});
|
||||
it("compares values without treating allocated IDs as conflicts", () => {
|
||||
const local = {
|
||||
...empty,
|
||||
sql: [{ id: 901, spriteId: 902, name: "Old chair", type: "s" }],
|
||||
furnidata: [
|
||||
{
|
||||
id: 902,
|
||||
name: "Old chair",
|
||||
description: "Old",
|
||||
revision: 1,
|
||||
type: "flooritem",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(compareFurniture(source, local).state).toBe("changed");
|
||||
expect(
|
||||
compareFurniture(source, local).fields.find((f) => f.label === "Name")
|
||||
?.changed,
|
||||
).toBe(true);
|
||||
});
|
||||
it("flags duplicate SQL classnames as conflicts", () => {
|
||||
expect(
|
||||
compareFurniture(source, {
|
||||
...empty,
|
||||
sql: [
|
||||
{ id: 1, spriteId: 1, name: "Chair", type: "s" },
|
||||
{ id: 2, spriteId: 2, name: "Chair", type: "s" },
|
||||
],
|
||||
}).state,
|
||||
).toBe("conflict");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface FurnitureInspection {
|
||||
classname: string;
|
||||
sql: Array<{ id: number; spriteId: number; name: string; type: string }>;
|
||||
catalog: Array<{
|
||||
id: number;
|
||||
pageId: number;
|
||||
credits: number;
|
||||
points: number;
|
||||
}>;
|
||||
furnidata: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
revision: number;
|
||||
type: string;
|
||||
}>;
|
||||
furnidataReadable: boolean;
|
||||
nitro: { exists: boolean | null; bytes: number };
|
||||
icon: { exists: boolean | null; bytes: number };
|
||||
}
|
||||
export function validateClassnames(value: unknown): string[] | null {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
!value.length ||
|
||||
value.length > 500 ||
|
||||
value.some(
|
||||
(v) =>
|
||||
typeof v !== "string" || v.length > 200 || !/^[a-zA-Z0-9_*-]+$/.test(v),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return [...new Set(value)];
|
||||
}
|
||||
export function compareFurniture(
|
||||
source: { name: string; description: string; revision: number; type: string },
|
||||
local: FurnitureInspection,
|
||||
) {
|
||||
const fd = local.furnidata[0],
|
||||
sql = local.sql[0];
|
||||
const values = [
|
||||
["Name", sql?.name ?? fd?.name, source.name],
|
||||
["Description", fd?.description, source.description],
|
||||
["Revision", fd?.revision, source.revision || undefined],
|
||||
[
|
||||
"Type",
|
||||
sql ? (sql.type === "i" ? "wallitem" : "flooritem") : fd?.type,
|
||||
source.type,
|
||||
],
|
||||
] as const;
|
||||
const fields = values.map(([label, before, after]) => ({
|
||||
label,
|
||||
before,
|
||||
after,
|
||||
changed:
|
||||
before !== undefined &&
|
||||
after !== undefined &&
|
||||
String(before) !== String(after),
|
||||
}));
|
||||
const state =
|
||||
local.sql.length > 1 || local.furnidata.length > 1
|
||||
? "conflict"
|
||||
: !local.furnidataReadable
|
||||
? "unknown"
|
||||
: !sql && !fd
|
||||
? "new"
|
||||
: fields.some((f) => f.changed)
|
||||
? "changed"
|
||||
: "present";
|
||||
return { state, fields } as const;
|
||||
}
|
||||
Reference in new issue
Block a user