Files
MineGeo/scripts/test-multiplayer-sql.mjs

369 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { readFile, readdir } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
// Isolated PostgreSQL: fixtures never enter production rooms or leaderboards.
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role;
create schema auth; create table auth.users(id uuid primary key);
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
for (const file of (await readdir("supabase/migrations"))
.filter((file) => file.endsWith(".sql"))
.sort())
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
const account = (
await db.query(
"insert into auth.users values(gen_random_uuid()) returning id",
)
).rows[0].id;
const dataset = (
await db.query(
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
values($1,'Multiplayer fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`,
[account],
)
).rows[0].id;
const manifest = {
name: "Multiplayer fixture",
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
viewpoints: Array.from({ length: 5 }, (_, i) => ({
id: `point${i}`,
x: 10 + i,
z: 10 + i,
y: 64,
})),
};
const revision = (
await db.query(
"insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
[dataset, JSON.stringify(manifest)],
)
).rows[0].id;
await db.query("update public.datasets set revision_id=$1 where id=$2", [
revision,
dataset,
]);
const secret = (n) => String(n).padStart(64, "0");
async function create(cap = 2) {
return (
await db.query(
"select public.create_multiplayer_room($1,'hard','Host',$2,$3) as id",
[dataset, cap, secret(1)],
)
).rows[0].id;
}
async function act(room, who, type = "view", payload = {}) {
return (
await db.query("select public.multiplayer_action($1,$2,$3,$4) as room", [
room,
secret(who),
type,
JSON.stringify(payload),
])
).rows[0].room;
}
for (const cap of [1, 17, null])
await assert.rejects(create(cap), /Choose between 2 and 16 players/);
const room = await create();
let view = await act(room, 1);
const host = view.you;
assert.equal(view.playerLimit, 2);
assert.equal(view.players.length, 1);
assert.equal(view.rounds.length, 5);
assert.ok(view.rounds.every((turn) => turn.results.length === 0));
const outsider = await act(room, 3);
assert.equal(outsider.you, null);
assert.deepEqual(outsider.players, []);
assert.deepEqual(outsider.rounds, []);
assert.ok(!JSON.stringify(view).includes('"viewpoint"'));
assert.ok(!JSON.stringify(view).includes("access_hash"));
await assert.rejects(act(room, 1, "start"), /Invite at least one friend/);
await assert.rejects(act(room, 3, "start"), /membership permission/);
const joined = await act(room, 2, "join", { name: "Friend" });
const friend = joined.you;
const chat = async (roomId, who, text = null, clientId = null) =>
(
await db.query("select public.multiplayer_chat($1,$2,$3,$4) as chat", [
roomId,
secret(who),
text,
clientId,
])
).rows[0].chat;
const messageId = randomUUID();
let messages = (await chat(room, 1, "Hello, friend!", messageId)).messages;
assert.equal(messages[0].name, "Host");
assert.equal(messages[0].playerId, host);
assert.equal(messages[0].text, "Hello, friend!");
assert.equal(typeof messages[0].id, "string");
assert.deepEqual((await chat(room, 2)).messages, messages);
assert.equal(
(await chat(room, 1, "Hello, friend!", messageId)).messages.length,
1,
"retry is idempotent",
);
await assert.rejects(chat(room, 1, "Spam", randomUUID()), /Too many chat/);
await assert.rejects(chat(room, 3), /Chat permission/);
await assert.rejects(chat(room, 3, "Spoof", randomUUID()), /Chat permission/);
for (const text of [" ", "x".repeat(501), "x\nname", "x\u202e", "x\u0085"])
await assert.rejects(chat(room, 2, text, randomUUID()), /Invalid chat/);
await assert.rejects(chat(room, 2, "Missing nonce"), /Invalid chat/);
messages = (
await chat(room, 2, "Привет (: <script>alert(1)</script>", randomUUID())
).messages;
assert.equal(messages[1].name, "Friend");
assert.equal(messages[1].text, "Привет (: <script>alert(1)</script>");
assert.ok(!JSON.stringify(messages).includes("access_hash"));
assert.ok(!JSON.stringify(messages).includes("viewpoint"));
const otherRoom = await create();
assert.deepEqual(
(await chat(otherRoom, 1)).messages,
[],
"room histories stay separate",
);
assert.equal(
(await act(room, 2, "join", { name: "Changed name" })).you,
friend,
);
await assert.rejects(act(room, 3, "join", { name: "Extra" }), /room is full/);
await assert.rejects(act(room, 2, "start"), /owner permission/);
view = await act(room, 1, "start");
assert.equal(view.status, "loading");
await assert.rejects(
act(room, 3, "join", { name: "Late" }),
/already started/,
);
await assert.rejects(
act(room, 1, "guess", { round: 0, x: 10, z: 10 }),
/Round is not active/,
);
assert.equal((await act(room, 1, "ready", { round: 0 })).status, "loading");
view = await act(room, 2, "ready", { round: 0 });
assert.equal(view.status, "playing");
const deadline = view.rounds[0].deadline;
assert.equal(
(await chat(room, 1)).messages.length,
2,
"chat persists into the game",
);
assert.equal(
(await act(room, 1, "ready", { round: 0 })).rounds[0].deadline,
deadline,
);
await assert.rejects(
act(room, 1, "guess", { round: 0, x: 65, z: 0 }),
/Guess outside map/,
);
const location = (
await db.query(
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=0",
[room],
)
).rows[0].viewpoint;
view = await act(room, 1, "guess", {
round: 0,
x: location.x,
z: location.z,
});
assert.equal(view.status, "playing");
assert.deepEqual(view.rounds[0].results, []);
assert.ok(
view.players.every((player) => player.total === 0),
"scores concealed until everyone finishes",
);
assert.deepEqual(view.myGuess, { x: location.x, z: location.z });
await act(room, 1, "guess", { round: 0, x: 0, z: 0 });
assert.equal(
(
await db.query(
"select count(*)::integer as count from public.multiplayer_guesses where room_id=$1 and player_id=$2",
[room, host],
)
).rows[0].count,
1,
);
view = await act(room, 2, "guess", { round: 0, x: 0, z: 0 });
assert.equal(view.status, "results");
assert.equal(
view.rounds[0].results.find((result) => result.playerId === host).score,
5000,
);
assert.ok(
view.rounds[0].results.find((result) => result.playerId === friend).score <
5000,
);
await assert.rejects(act(room, 2, "next", { round: 0 }), /owner permission/);
assert.equal(
(await act(room, 1, "guess", { round: 0, x: 0, z: 0 })).status,
"results",
"retry cannot alter a submitted guess",
);
for (let round = 1; round < 5; round++) {
await act(room, 1, "next", { round: round - 1 });
await act(room, 1, "ready", { round });
await act(room, 2, "ready", { round });
const point = (
await db.query(
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=$2",
[room, round],
)
).rows[0].viewpoint;
await act(room, 1, "guess", { round, x: point.x, z: point.z });
if (round === 2) {
await db.query(
"update public.multiplayer_rounds set deadline=clock_timestamp()-interval '1 second' where room_id=$1 and round_number=$2",
[room, round],
);
view = await act(room, 2, "guess", { round, x: point.x, z: point.z });
assert.equal(
view.rounds[round].results.find((result) => result.playerId === friend)
.score,
0,
);
assert.equal(
view.rounds[round].results.find((result) => result.playerId === friend)
.timedOut,
true,
);
} else view = await act(room, 2, "guess", { round, x: 0, z: 0 });
}
assert.equal(view.status, "finished");
assert.equal(
(await chat(room, 2)).messages.length,
2,
"chat remains available at final standings",
);
assert.equal(view.players[0].id, host);
assert.equal(view.players[0].total, 25000);
assert.equal(view.players[0].rank, 1);
assert.equal(view.players[1].rank, 2);
assert.equal(view.rounds.flatMap((turn) => turn.results).length, 10);
const capacity = await create(16);
for (let n = 2; n <= 16; n++)
await act(capacity, n, "join", { name: `Player ${n}` });
await assert.rejects(
act(capacity, 17, "join", { name: "Overflow" }),
/room is full/,
);
assert.equal((await act(capacity, 1)).playerCount, 16);
const removed = (await act(capacity, 16)).you;
await act(capacity, 1, "kick", { playerId: removed });
await assert.rejects(chat(capacity, 16), /Chat permission/);
assert.equal((await act(capacity, 16)).you, null);
await act(capacity, 17, "join", { name: "Replacement" });
await act(capacity, 1, "leave");
await assert.rejects(
chat(capacity, 1, "Left", randomUUID()),
/Chat permission/,
);
assert.equal(
(await act(capacity, 2)).hostId,
(await act(capacity, 2)).you,
"ownership transfers when host leaves",
);
await assert.rejects(
act(capacity, 18, "join", { name: "player 2" }),
/name is already taken/,
);
const stalled = await create();
await act(stalled, 2, "join", { name: "Offline" });
await act(stalled, 1, "start");
await act(stalled, 1, "ready", { round: 0 });
await db.query(
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
[stalled],
);
assert.equal(
(await act(stalled, 1)).status,
"playing",
"offline loader cannot block the match",
);
await db.query(
"update public.multiplayer_rounds set deadline=clock_timestamp()-interval '1 second' where room_id=$1 and round_number=0",
[stalled],
);
view = await act(stalled, 1);
assert.equal(view.status, "results");
assert.ok(
view.players.every((player) => player.rank === 1),
"equal totals share a rank",
);
await db.query(
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
[stalled],
);
assert.equal(
(await act(stalled, 1)).round,
1,
"rounds progress without host clicks",
);
await act(stalled, 2, "leave");
await act(stalled, 1, "ready", { round: 1 });
const point = (
await db.query(
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=1",
[stalled],
)
).rows[0].viewpoint;
view = await act(stalled, 1, "guess", { round: 1, x: point.x, z: point.z });
assert.equal(view.status, "results", "departed players cannot block scoring");
await act(stalled, 1, "close");
await assert.rejects(chat(stalled, 1), /closed or expired/);
assert.equal((await act(stalled, 1)).status, "closed");
const expired = await create();
await db.query(
"update public.multiplayer_rooms set created_at=now()-interval '25 hours' where id=$1",
[expired],
);
await assert.rejects(chat(expired, 1), /closed or expired/);
const history = await create();
const historyHost = (await act(history, 1)).you;
await db.query(
`insert into public.multiplayer_messages(room_id,player_id,client_id,display_name,body,created_at)
select $1,$2,gen_random_uuid(),'Host','message ' || n,now()-interval '2 minutes' from generate_series(1,120) n`,
[history, historyHost],
);
const recent = (await chat(history, 1)).messages;
assert.equal(recent.length, 100);
assert.equal(recent[0].text, "message 21");
assert.equal(recent[99].text, "message 120");
await db.query(
"update public.multiplayer_messages set created_at=clock_timestamp()-interval '2 seconds' where room_id=$1",
[history],
);
await assert.rejects(
chat(history, 1, "Minute flood", randomUUID()),
/Too many chat/,
);
for (const role of ["anon", "authenticated"]) {
await db.exec(`set role ${role}`);
for (const table of [
"multiplayer_rooms",
"multiplayer_players",
"multiplayer_rounds",
"multiplayer_guesses",
"multiplayer_messages",
])
await assert.rejects(
db.query(`select * from public.${table}`),
/permission denied/,
);
await assert.rejects(act(room, 1), /permission denied/);
await assert.rejects(chat(room, 1), /permission denied/);
await assert.rejects(create(), /permission denied/);
await db.exec("reset role");
}
console.log(
"Multiplayer SQL checks passed: room chat privacy, membership revocation, message validation, bounded history, rate limits, retries, expiration, 16-player cap, synchronized rounds, and client-role isolation.",
);
} finally {
await db.close();
}