Add map import and export with durable refresh progress
This commit is contained in:
170
tests/map-sharing.test.ts
Normal file
170
tests/map-sharing.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
import {
|
||||
archiveSchema,
|
||||
importManifest,
|
||||
readMapArchive,
|
||||
sha256,
|
||||
archiveManifestName,
|
||||
} from "../shared/map-archive";
|
||||
import { datasetSchema, faces, parseUploadManifest } from "../shared/protocol";
|
||||
import {
|
||||
archiveSignature,
|
||||
verifyArchiveSignature,
|
||||
} from "../lib/map-archive-signature";
|
||||
import { processFabricImages } from "../lib/fabric-images";
|
||||
|
||||
async function fixture(source: "fabric" | "plugin" = "plugin") {
|
||||
const bytes = await sharp({
|
||||
create: { width: 256, height: 256, channels: 3, background: "#78ab23" },
|
||||
})
|
||||
.webp()
|
||||
.toBuffer();
|
||||
const viewpoints = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
x: i,
|
||||
y: 70,
|
||||
z: i,
|
||||
}));
|
||||
const files: Record<string, Uint8Array> = { "map.webp": bytes };
|
||||
for (const p of viewpoints)
|
||||
for (const f of faces) files[`${p.id}/${f}.webp`] = bytes;
|
||||
const archive = archiveSchema.parse({
|
||||
format: "minegeo-map/v1",
|
||||
name: "Shared world",
|
||||
description: "Map for friends",
|
||||
serverName: "Test server",
|
||||
source,
|
||||
dimension: "minecraft:overworld",
|
||||
north: "-z",
|
||||
minecraftVersion: "26.2",
|
||||
bounds: { minX: 0, minZ: 0, maxX: 32, maxZ: 32 },
|
||||
viewpoints,
|
||||
originalMapId: randomUUID(),
|
||||
originalOwner: "Original explorer",
|
||||
signature: "0".repeat(64),
|
||||
assets: await Promise.all(
|
||||
Object.entries(files).map(async ([path, content]) => ({
|
||||
path,
|
||||
bytes: content.length,
|
||||
sha256: await sha256(content),
|
||||
})),
|
||||
),
|
||||
});
|
||||
archive.signature = archiveSignature(archive, "test-key");
|
||||
files[archiveManifestName] = strToU8(JSON.stringify(archive));
|
||||
return { archive, files };
|
||||
}
|
||||
|
||||
test("ZIP roundtrip preserves source, six faces, coordinates, and signed original owner", async () => {
|
||||
for (const source of ["plugin", "fabric"] as const) {
|
||||
const { archive, files } = await fixture(source);
|
||||
for (const level of [0, 6] as const) {
|
||||
const restored = await readMapArchive(zipSync(files, { level }));
|
||||
assert.deepEqual(restored.archive, archive);
|
||||
const manifest = importManifest(restored.archive);
|
||||
assert.equal(manifest.source, source);
|
||||
assert.equal(manifest.mediaFormat, "webp");
|
||||
assert.equal(manifest.assets.length, 31);
|
||||
assert.equal(manifest.contributionTo, undefined);
|
||||
assert.equal(manifest.mapPath, undefined);
|
||||
assert.equal(manifest.serverAddress, undefined);
|
||||
verifyArchiveSignature(restored.archive, "test-key");
|
||||
assert.throws(() => parseUploadManifest(manifest), /Use map import/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("ZIP import rejects corruption, missing faces, unsafe files, and false ownership", async () => {
|
||||
const { archive, files } = await fixture();
|
||||
await assert.rejects(
|
||||
readMapArchive(zipSync({ ...files, "p0/px.webp": strToU8("corrupt") })),
|
||||
/checksum or size/,
|
||||
);
|
||||
const missing = { ...files };
|
||||
delete missing["p0/px.webp"];
|
||||
await assert.rejects(
|
||||
readMapArchive(zipSync(missing)),
|
||||
/unexpected files|checksum/,
|
||||
);
|
||||
await assert.rejects(
|
||||
readMapArchive(zipSync({ ...files, "../secret": strToU8("file") })),
|
||||
/unexpected/,
|
||||
);
|
||||
await assert.rejects(readMapArchive(strToU8("not a zip")), /ZIP/);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyArchiveSignature(
|
||||
{ ...archive, originalOwner: "Impersonator" },
|
||||
"test-key",
|
||||
),
|
||||
/ownership/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyArchiveSignature({ ...archive, name: "Changed world" }, "test-key"),
|
||||
/ownership/,
|
||||
);
|
||||
assert.throws(() =>
|
||||
importManifest({ ...archive, contributionTo: randomUUID() }),
|
||||
);
|
||||
assert.throws(() =>
|
||||
importManifest({
|
||||
...archive,
|
||||
viewpoints: archive.viewpoints.map((p) => ({
|
||||
...p,
|
||||
assetRoot: randomUUID(),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("ZIP extraction rejects oversized entries before inflating them", async () => {
|
||||
const { files } = await fixture();
|
||||
const zip = zipSync(files, { level: 0 });
|
||||
// The first local header advertises an enormous uncompressed map.
|
||||
new DataView(zip.buffer).setUint32(22, 60 * 1024 * 1024, true);
|
||||
await assert.rejects(readMapArchive(zip), /oversized/);
|
||||
});
|
||||
|
||||
test("rendered server map imports are validated and processed without the terrain renderer", async () => {
|
||||
const { archive, files } = await fixture();
|
||||
const manifest = importManifest(archive);
|
||||
const images = new Map<string, Buffer>();
|
||||
await processFabricImages(manifest, {
|
||||
signal: new AbortController().signal,
|
||||
download: async (path) => Buffer.from(files[path]),
|
||||
save: async (path, bytes) => {
|
||||
images.set(path, bytes);
|
||||
},
|
||||
progress: async () => {},
|
||||
});
|
||||
assert.equal(images.size, 36);
|
||||
assert.equal((await sharp(images.get("map.webp")).metadata()).format, "webp");
|
||||
assert.equal(
|
||||
(await sharp(images.get("p0/preview.webp")).metadata()).width,
|
||||
512,
|
||||
);
|
||||
assert.equal(datasetSchema.parse(manifest).viewpoints.length, 5);
|
||||
const corrupt = Buffer.from("not an image");
|
||||
const bad = datasetSchema.parse({
|
||||
...manifest,
|
||||
assets: manifest.assets.map((a) =>
|
||||
a.path === "map.webp"
|
||||
? { ...a, bytes: corrupt.length, sha256: "0".repeat(64) }
|
||||
: a,
|
||||
),
|
||||
});
|
||||
await assert.rejects(
|
||||
processFabricImages(bad, {
|
||||
signal: new AbortController().signal,
|
||||
download: async () => corrupt,
|
||||
save: async () => {},
|
||||
progress: async () => {},
|
||||
}),
|
||||
/checksum/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user