Harden gameplay timing and map loading

This commit is contained in:
2026-10-05 15:15:35 +05:00
parent 8b31891776
commit dcc3779262
36 changed files with 1050 additions and 205 deletions

46
tests/client-api.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { api } from "../lib/client-api";
test("API sends explicit falsy JSON bodies and prevents cached private responses", async (t) => {
const fetch = t.mock.method(
globalThis,
"fetch",
async (_url: unknown, options: RequestInit) => {
assert.equal(options.method, "POST");
assert.equal(options.body, "false");
assert.equal(options.cache, "no-store");
return Response.json({ ok: true });
},
);
assert.deepEqual(await api("test", false), { ok: true });
assert.equal(fetch.mock.callCount(), 1);
});
test("API explains HTML gateway failures, JSON errors, and empty error responses", async (t) => {
const fetch = t.mock.method(
globalThis,
"fetch",
async () => new Response("Bad gateway", { status: 502 }),
);
await assert.rejects(api("test"), /unreadable response/);
fetch.mock.mockImplementation(async () =>
Response.json({ error: "Map unavailable" }, { status: 404 }),
);
await assert.rejects(api("test"), /Map unavailable/);
fetch.mock.mockImplementation(async () =>
Response.json(null, { status: 503 }),
);
await assert.rejects(api("test"), /Request failed/);
});
test("API reports timeouts and offline connections with a recovery action", async (t) => {
const fetch = t.mock.method(globalThis, "fetch", async () => {
throw new DOMException("timeout", "TimeoutError");
});
await assert.rejects(api("test"), /timed out.*Reconnect/);
fetch.mock.mockImplementation(async () => {
throw new TypeError("Failed to fetch");
});
await assert.rejects(api("test"), /Check your connection/);
});

View File

@@ -0,0 +1,59 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import * as THREE from "three";
import { loadPanoramaTextures } from "../lib/panorama-textures";
function texture() {
const value = new THREE.Texture();
let disposals = 0;
value.addEventListener("dispose", () => disposals++);
return { value, disposals: () => disposals };
}
test("panorama textures retain face order and are owned separately by each viewer", async () => {
const loader = { loadAsync: async () => new THREE.Texture() };
const first = await loadPanoramaTextures(["px", "nx"], loader);
const second = await loadPanoramaTextures(["px", "nx"], loader);
assert.equal(first.length, 2);
assert.equal(first[0].colorSpace, THREE.SRGBColorSpace);
assert.notEqual(first[0], second[0]);
[...first, ...second].forEach((value) => value.dispose());
});
test("a failed panorama frees both loaded faces and faces that complete later", async () => {
const early = texture(),
late = texture();
let finish!: (value: THREE.Texture) => void;
const loader = {
loadAsync: (url: string) =>
url === "early"
? Promise.resolve(early.value)
: url === "late"
? new Promise<THREE.Texture>((resolve) => {
finish = resolve;
})
: Promise.reject(new Error("missing face")),
};
await assert.rejects(
loadPanoramaTextures(["early", "late", "missing"], loader),
/missing face/,
);
assert.equal(early.disposals(), 1);
finish(late.value);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(late.disposals(), 1);
});
test("stalled panoramas time out and release textures that arrive afterward", async () => {
const late = texture();
let finish!: (value: THREE.Texture) => void;
const loader = {
loadAsync: () =>
new Promise<THREE.Texture>((resolve) => {
finish = resolve;
}),
};
await assert.rejects(loadPanoramaTextures(["late"], loader, 5), /timed out/);
finish(late.value);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(late.disposals(), 1);
});

View File

@@ -58,6 +58,26 @@ test("the testing switch retains JSON checks for requests without an origin", ()
);
});
test("production never enables the environment origin bypass", () => {
const env = process.env as Record<string, string | undefined>;
const previousNode = env.NODE_ENV,
previousBypass = env.MINEGEO_ALLOW_ANY_ORIGIN;
try {
env.NODE_ENV = "production";
env.MINEGEO_ALLOW_ANY_ORIGIN = "true";
assert.throws(
() =>
verifyOrigin(makeRequest("localhost:3000", "https://foreign.example")),
/Invalid request origin/,
);
} finally {
if (previousNode === undefined) delete env.NODE_ENV;
else env.NODE_ENV = previousNode;
if (previousBypass === undefined) delete env.MINEGEO_ALLOW_ANY_ORIGIN;
else env.MINEGEO_ALLOW_ANY_ORIGIN = previousBypass;
}
});
test("accepts a Mac LAN origin when Next uses localhost internally", () => {
const request = makeRequest("192.168.1.50:3000", "http://192.168.1.50:3000");
assert.equal(requestOrigin(request), "http://192.168.1.50:3000");

20
tests/round-clock.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { roundClock } from "../shared/round-clock";
test("round timer accounts for return latency and stops at the server deadline", () => {
const clock = roundClock(100000, 1000, 5000);
const deadline = new Date(105000).toISOString();
assert.equal(clock.remaining(deadline, 5000), 3);
assert.equal(clock.remaining(deadline, 7001), 1);
assert.equal(clock.remaining(deadline, 8000), 0);
assert.equal(clock.remaining(deadline, 9000), 0);
});
test("round timer relies on elapsed time even if the device wall clock changes", (t) => {
const clock = roundClock(100000, 1000, 1000);
t.mock.method(Date, "now", () => -10000000);
assert.equal(clock.remaining(new Date(110000).toISOString(), 5000), 6);
t.mock.method(Date, "now", () => 10000000);
assert.equal(clock.remaining(new Date(110000).toISOString(), 11000), 0);
});

View File

@@ -0,0 +1,69 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { validateRuntime } from "../scripts/runtime-config.mjs";
const env = {
NEXT_PUBLIC_SUPABASE_URL: "https://project.supabase.co",
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_example",
SUPABASE_SECRET_KEY: "sb_secret_example",
MINEGEO_URL: "https://minegeo.example",
};
test("production startup rejects CSRF bypass, secret exposure and missing config", () => {
assert.throws(
() =>
validateRuntime(
{ ...env, MINEGEO_ALLOW_ANY_ORIGIN: "true" },
{ production: true },
),
/must be false/,
);
assert.throws(
() =>
validateRuntime({
...env,
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: env.SUPABASE_SECRET_KEY,
}),
/server secret/,
);
assert.throws(
() => validateRuntime({ ...env, SUPABASE_SECRET_KEY: "" }),
/Missing configuration.*SUPABASE_SECRET_KEY/,
);
});
test("production requires a secure public origin while allowing local production checks", () => {
for (const url of [
"http://minegeo.example",
"https://user:pass@minegeo.example",
"https://minegeo.example/path",
"https://minegeo.example?x=1",
])
assert.throws(
() => validateRuntime({ ...env, MINEGEO_URL: url }, { production: true }),
/HTTPS origin/,
);
assert.equal(
validateRuntime(
{ ...env, MINEGEO_URL: "http://localhost:3000" },
{ production: true },
).workerHeap,
4096,
);
assert.equal(validateRuntime(env, { production: true }).workerHeap, 4096);
});
test("startup validates worker heap and panorama size before spawning processes", () => {
assert.throws(
() => validateRuntime({ ...env, MINEGEO_WORKER_HEAP_MB: "NaN" }),
/WORKER_HEAP/,
);
assert.throws(
() => validateRuntime({ ...env, MINEGEO_FACE_SIZE: "8192" }),
/FACE_SIZE/,
);
assert.equal(
validateRuntime({
...env,
MINEGEO_WORKER_HEAP_MB: "2048",
MINEGEO_FACE_SIZE: "256",
}).workerHeap,
2048,
);
});

View File

@@ -0,0 +1,18 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkServiceError, ServiceError } from "../lib/service-error";
test("backend failures use 503 and keep database internals out of browser errors", (t) => {
t.mock.method(console, "error", () => {});
assert.throws(() => checkServiceError({ code: "42P01", message: 'relation "private_table" does not exist' }), (error) => {
assert.ok(error instanceof ServiceError);
assert.equal(error.status, 503);
assert.doesNotMatch(error.message, /private_table|42P01/);
return true;
});
});
test("missing records and expected RPC validation keep actionable statuses and messages", () => {
assert.throws(() => checkServiceError({ code: "PGRST116", message: "no rows" }), (error) => error instanceof ServiceError && error.status === 404);
assert.throws(() => checkServiceError({ code: "P0001", message: "Guess outside map" }), /Guess outside map/);
assert.doesNotThrow(() => checkServiceError(null));
});