fix(catalog): check source assets and offer reachable import alternatives
This commit is contained in:
1 parent
8c1efae296
commit
159b1f7d1b
10 files changed
+646
-56
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
source: vi.fn(),
|
||||
official: vi.fn(),
|
||||
inspect: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (options: unknown, handler: unknown) => {
|
||||
mocks.guard(options);
|
||||
return handler;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
|
||||
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
|
||||
getOfficialHabboFurnidata: mocks.official,
|
||||
}));
|
||||
vi.mock("@/lib/services/furniture-source-assets", () => ({
|
||||
inspectSourceAssets: mocks.inspect,
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
const item = {
|
||||
id: 1,
|
||||
classname: "chair",
|
||||
name: "Chair",
|
||||
description: "",
|
||||
revision: 1,
|
||||
type: "flooritem",
|
||||
category: "other",
|
||||
};
|
||||
const request = (body: unknown) =>
|
||||
new NextRequest("http://localhost/api/admin/studio/source-assets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
beforeEach(() => {
|
||||
mocks.source.mockReset().mockResolvedValue(null);
|
||||
mocks.official.mockReset().mockResolvedValue(new Map());
|
||||
mocks.inspect
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
classname: "chair",
|
||||
state: "missing",
|
||||
revision: 1,
|
||||
alternatives: [],
|
||||
});
|
||||
});
|
||||
it("requires import permission", () =>
|
||||
expect(mocks.guard).toHaveBeenCalledWith({
|
||||
permission: PERMS.ASSETS_IMPORT,
|
||||
}));
|
||||
it.each([
|
||||
[],
|
||||
Array(21).fill(item),
|
||||
[{ ...item, classname: "../secret" }],
|
||||
[{ ...item, revision: -1 }],
|
||||
[null],
|
||||
])("rejects invalid items before network checks: %j", async (items) => {
|
||||
expect((await POST(request({ items }))).status).toBe(400);
|
||||
expect(mocks.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects unknown configured sources", async () => {
|
||||
expect(
|
||||
(await POST(request({ items: [item], sourceId: "missing" }))).status,
|
||||
).toBe(404);
|
||||
expect(mocks.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses server source configuration instead of client URLs", async () => {
|
||||
const source = { id: "custom", nitroBaseUrl: "https://configured.example" };
|
||||
mocks.source.mockResolvedValue(source);
|
||||
const response = await POST(
|
||||
request({
|
||||
items: [item],
|
||||
sourceId: "custom",
|
||||
nitroBaseUrl: "https://untrusted.example",
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.inspect).toHaveBeenCalledWith(item, [], source);
|
||||
expect((await response.json()).items[0].state).toBe("missing");
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import type {
|
||||
SourceAssetCheck,
|
||||
SourceAssetItem,
|
||||
} from "@/lib/furni/source-assets";
|
||||
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { getSource } from "@/lib/services/clone-sources";
|
||||
import { inspectSourceAssets } from "@/lib/services/furniture-source-assets";
|
||||
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (
|
||||
!Array.isArray(body?.items) ||
|
||||
body.items.length > 20 ||
|
||||
!validateClassnames(
|
||||
body.items.map((item: SourceAssetItem) => item?.classname),
|
||||
)
|
||||
)
|
||||
return apiError("Provide 1–20 furniture items", 400);
|
||||
if (
|
||||
body.items.some(
|
||||
(item: SourceAssetItem) =>
|
||||
!Number.isSafeInteger(item.revision) ||
|
||||
item.revision < 0 ||
|
||||
typeof item.name !== "string" ||
|
||||
item.name.length > 500,
|
||||
)
|
||||
)
|
||||
return apiError("Invalid source metadata", 400);
|
||||
const source = body.sourceId ? await getSource(body.sourceId) : null;
|
||||
if (body.sourceId && !source) return apiError("Source not found", 404);
|
||||
const entries = await getOfficialHabboFurnidata();
|
||||
const official: SourceAssetItem[] = [...entries.values()].map((entry) => ({
|
||||
id: entry.id,
|
||||
classname: entry.classname,
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
type: entry.itemType === "i" ? "wallitem" : "flooritem",
|
||||
revision: entry.revision,
|
||||
category: entry.category,
|
||||
}));
|
||||
const results: SourceAssetCheck[] = [];
|
||||
for (let offset = 0; offset < body.items.length; offset += 4)
|
||||
results.push(
|
||||
...(await Promise.all(
|
||||
body.items
|
||||
.slice(offset, offset + 4)
|
||||
.map((item: SourceAssetItem) =>
|
||||
inspectSourceAssets(item, official, source),
|
||||
),
|
||||
)),
|
||||
);
|
||||
return apiOk({ items: results });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user