Harden gameplay timing and map loading
This commit is contained in:
167
scripts/test-production.mjs
Normal file
167
scripts/test-production.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||
const origin = new URL(
|
||||
process.argv[2] || process.env.MINEGEO_URL || "http://localhost:3000",
|
||||
).origin;
|
||||
const db = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.SUPABASE_SECRET_KEY,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
let sessionId, cookie;
|
||||
async function request(path, body, options = {}) {
|
||||
return fetch(origin + path, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { "content-type": "application/json", origin }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
}
|
||||
async function json(path, body) {
|
||||
const response = await request("/api/v1/" + path, body);
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`${path}: ${await response.clone().text()}`,
|
||||
);
|
||||
assert.match(response.headers.get("cache-control"), /no-store/);
|
||||
return response.json();
|
||||
}
|
||||
try {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/servers",
|
||||
"/downloads",
|
||||
"/about",
|
||||
"/log-in",
|
||||
"/fonts/VT323-Regular.ttf",
|
||||
"/images/example-map.webp",
|
||||
"/tools/plugin/minegeo-plugin-1.21.11.jar",
|
||||
"/tools/mod/minegeo-fabric-1.21.11.jar",
|
||||
]) {
|
||||
const response = await request(path);
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.equal(response.headers.get("x-frame-options"), "DENY");
|
||||
assert.ok((await response.arrayBuffer()).byteLength > 0, path);
|
||||
}
|
||||
assert.equal((await json("health")).ok, true);
|
||||
for (const path of [
|
||||
"/servers/not-a-uuid",
|
||||
"/servers/00000000-0000-4000-8000-000000000000",
|
||||
"/api/v1/catalog/extra",
|
||||
]) {
|
||||
assert.equal((await request(path)).status, 404, path);
|
||||
}
|
||||
for (const path of ["datasets", "tokens", "profile"]) {
|
||||
assert.equal(
|
||||
(await request("/api/v1/" + path)).status,
|
||||
401,
|
||||
`guest ${path}`,
|
||||
);
|
||||
}
|
||||
const forbidden = await request(
|
||||
"/api/v1/sessions",
|
||||
{},
|
||||
{ headers: { origin: "https://foreign.example" } },
|
||||
);
|
||||
assert.equal(forbidden.status, 403);
|
||||
const malformed = await fetch(origin + "/api/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin },
|
||||
body: "{",
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
assert.equal(malformed.status, 400);
|
||||
assert.equal((await malformed.json()).error, "Invalid JSON request");
|
||||
const catalog = await json("catalog");
|
||||
assert.ok(
|
||||
catalog.maps.length,
|
||||
"Publish at least one map before verifying gameplay",
|
||||
);
|
||||
const map = catalog.maps[0];
|
||||
const created = await request("/api/v1/sessions", {
|
||||
datasetId: map.id,
|
||||
difficulty: "hard",
|
||||
});
|
||||
assert.equal(created.status, 200, await created.clone().text());
|
||||
sessionId = (await created.json()).id;
|
||||
cookie = created.headers.get("set-cookie").split(";")[0];
|
||||
const session = await json("sessions/" + sessionId);
|
||||
assert.equal(session.rounds.length, 5);
|
||||
assert.ok(
|
||||
session.rounds.every(
|
||||
(round) => round.faces.length === 6 && round.faces.every(Boolean),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
(await fetch(origin + "/api/v1/sessions/" + sessionId)).status,
|
||||
404,
|
||||
"another guest cannot read the session",
|
||||
);
|
||||
// Exercise actual signed Storage reads, not just the URL response shape.
|
||||
for (const url of [session.map, ...session.rounds[0].faces]) {
|
||||
const image = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
assert.equal(image.status, 200);
|
||||
assert.match(image.headers.get("content-type"), /image\/webp/);
|
||||
assert.ok((await image.arrayBuffer()).byteLength > 0);
|
||||
}
|
||||
for (let round = 0; round < 5; round++) {
|
||||
const first = await json(`sessions/${sessionId}/activate`, { round });
|
||||
const duplicate = await json(`sessions/${sessionId}/activate`, { round });
|
||||
assert.equal(
|
||||
first.deadline,
|
||||
duplicate.deadline,
|
||||
"activation does not reset the timer",
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/api/v1/sessions/${sessionId}/guess`, {
|
||||
round,
|
||||
x: map.bounds.maxX + 1,
|
||||
z: map.bounds.minZ,
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const guess = {
|
||||
round,
|
||||
x: (map.bounds.minX + map.bounds.maxX) / 2,
|
||||
z: (map.bounds.minZ + map.bounds.maxZ) / 2,
|
||||
};
|
||||
const result = await json(`sessions/${sessionId}/guess`, guess);
|
||||
assert.ok(result.score >= 0 && result.score <= 5000);
|
||||
assert.equal(result.timedOut, false);
|
||||
assert.deepEqual(
|
||||
await json(`sessions/${sessionId}/guess`, guess),
|
||||
result,
|
||||
"submission retries are idempotent",
|
||||
);
|
||||
}
|
||||
assert.equal((await json("sessions/" + sessionId)).round, 5);
|
||||
console.log(
|
||||
"Production HTTP/API/Storage smoke passed: pages, headers, privacy, origin checks, signed media and all five rounds.",
|
||||
);
|
||||
} finally {
|
||||
if (sessionId && cookie) {
|
||||
const secret = cookie.slice(cookie.indexOf("=") + 1);
|
||||
const { error } = await db
|
||||
.from("game_sessions")
|
||||
.delete()
|
||||
.eq("id", sessionId)
|
||||
.eq("access_hash", createHash("sha256").update(secret).digest("hex"))
|
||||
.is("owner_id", null);
|
||||
if (error)
|
||||
throw new Error("Smoke session cleanup failed: " + error.message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user