Add Catalog Studio inspection and guided import review
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type FurnitureInspection,
|
||||
validateClassnames,
|
||||
} from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { readFurniData } from "@/lib/services/furni-data";
|
||||
|
||||
async function asset(directory: string, name: string) {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(directory, name));
|
||||
return { exists: stat.isFile(), bytes: stat.size };
|
||||
} catch (error) {
|
||||
return {
|
||||
exists: (error as NodeJS.ErrnoException).code === "ENOENT" ? false : null,
|
||||
bytes: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Read-only inspection lives outside import routes so it cannot enqueue a Git export.
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const names = validateClassnames(body?.classnames);
|
||||
if (!names)
|
||||
return apiError("Provide 1–500 valid furniture classnames", 400);
|
||||
const namesSql = sql.join(
|
||||
names.map((name) => sql`${name}`),
|
||||
sql`, `,
|
||||
);
|
||||
const [rows] = (await db.execute(
|
||||
sql`SELECT id, sprite_id AS spriteId, item_name AS classname, public_name AS name, type FROM items_base WHERE item_name IN (${namesSql})`,
|
||||
)) as unknown as [
|
||||
Array<FurnitureInspection["sql"][number] & { classname: string }>,
|
||||
unknown,
|
||||
];
|
||||
const [offers] = (await db.execute(
|
||||
sql`SELECT ib.item_name AS classname, ci.id, ci.page_id AS pageId, ci.cost_credits AS credits, ci.cost_points AS points FROM items_base ib JOIN catalog_items ci ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE ib.item_name IN (${namesSql})`,
|
||||
)) as unknown as [
|
||||
Array<FurnitureInspection["catalog"][number] & { classname: string }>,
|
||||
unknown,
|
||||
];
|
||||
let readable = true;
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
data = await readFurniData();
|
||||
} catch {
|
||||
readable = false;
|
||||
}
|
||||
const entries = new Map<string, FurnitureInspection["furnidata"]>();
|
||||
for (const [section, type] of [
|
||||
["roomitemtypes", "flooritem"],
|
||||
["wallitemtypes", "wallitem"],
|
||||
] as const) {
|
||||
const list = (
|
||||
data[section] as
|
||||
| { furnitype?: Array<Record<string, unknown>> }
|
||||
| undefined
|
||||
)?.furnitype;
|
||||
if (!Array.isArray(list)) {
|
||||
readable = false;
|
||||
continue;
|
||||
}
|
||||
for (const entry of list) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
readable = false;
|
||||
continue;
|
||||
}
|
||||
const name = String(entry.classname ?? "");
|
||||
if (!names.includes(name)) continue;
|
||||
const values = entries.get(name) ?? [];
|
||||
values.push({
|
||||
id: Number(entry.id),
|
||||
name: String(entry.name ?? ""),
|
||||
description: String(entry.description ?? ""),
|
||||
revision: Number(entry.revision ?? 0),
|
||||
type,
|
||||
});
|
||||
entries.set(name, values);
|
||||
}
|
||||
}
|
||||
const dirs = await getFurniAssetDirs();
|
||||
const items: FurnitureInspection[] = [];
|
||||
for (let offset = 0; offset < names.length; offset += 20) {
|
||||
items.push(
|
||||
...(await Promise.all(
|
||||
names.slice(offset, offset + 20).map(async (classname) => {
|
||||
const base = classname.split("*")[0];
|
||||
const [nitro, icon] = await Promise.all([
|
||||
asset(dirs.nitroDir, `${base}.nitro`),
|
||||
asset(dirs.iconDir, `${classname.replace(/\*/g, "_")}_icon.png`),
|
||||
]);
|
||||
return {
|
||||
classname,
|
||||
sql: rows.filter((row) => row.classname === classname),
|
||||
catalog: offers.filter((row) => row.classname === classname),
|
||||
furnidata: entries.get(classname) ?? [],
|
||||
furnidataReadable: readable,
|
||||
nitro,
|
||||
icon,
|
||||
};
|
||||
}),
|
||||
)),
|
||||
);
|
||||
}
|
||||
return apiOk({ items });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user