Harden gameplay timing and map loading
This commit is contained in:
46
tests/client-api.test.ts
Normal file
46
tests/client-api.test.ts
Normal 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/);
|
||||
});
|
||||
59
tests/panorama-textures.test.ts
Normal file
59
tests/panorama-textures.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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
20
tests/round-clock.test.ts
Normal 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);
|
||||
});
|
||||
69
tests/runtime-config.test.ts
Normal file
69
tests/runtime-config.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
18
tests/service-error.test.ts
Normal file
18
tests/service-error.test.ts
Normal 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));
|
||||
});
|
||||
Reference in New Issue
Block a user