Add Catalog Studio inspection and guided import review
CI / check (push) Successful in 1m39s
CI / deploy (push) Successful in 1m18s
CI / e2e (push) Successful in 25s

This commit is contained in:
Simo committed 2026-09-05 13:10:43 +02:00
1 parent 2578bf6a09
commit 26f071117b
7 files changed
+961 -44

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
+67
View File
@@ -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");
});
});
+70
View File
@@ -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;
}