Add MineGeo gameplay systems and interface
This commit is contained in:
247
scripts/test-admin-permissions-production.mjs
Normal file
247
scripts/test-admin-permissions-production.mjs
Normal file
@@ -0,0 +1,247 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID, randomBytes } from "node:crypto";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
process.loadEnvFile(".env.local");
|
||||
const origin = process.argv[2] || "https://minegeo.vercel.app";
|
||||
const db = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.SUPABASE_SECRET_KEY,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
const accounts = [],
|
||||
maps = [];
|
||||
const checked = (r) => {
|
||||
if (r.error) throw new Error(r.error.message);
|
||||
return r.data;
|
||||
};
|
||||
async function request(
|
||||
account,
|
||||
path,
|
||||
body,
|
||||
method = body === undefined ? "GET" : "POST",
|
||||
status = 200,
|
||||
) {
|
||||
const response = await fetch(`${origin}/api/v1/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
origin,
|
||||
"content-type": "application/json",
|
||||
...(account?.cookie ? { cookie: account.cookie } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
const data = await response.json();
|
||||
assert.equal(response.status, status, `${path}: ${JSON.stringify(data)}`);
|
||||
return data;
|
||||
}
|
||||
async function account(role) {
|
||||
const email = `admin-permissions-${randomUUID()}@example.com`,
|
||||
password = randomBytes(24).toString("base64url");
|
||||
const { user } = checked(
|
||||
await db.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: { display_name: `Permissions ${role} fixture` },
|
||||
}),
|
||||
);
|
||||
const fixture = { id: user.id, cookie: "" };
|
||||
accounts.push(fixture);
|
||||
checked(
|
||||
await db.from("profiles").insert({
|
||||
id: user.id,
|
||||
role,
|
||||
display_name: `Permissions ${role} fixture`,
|
||||
}),
|
||||
);
|
||||
const response = await fetch(`${origin}/api/v1/auth`, {
|
||||
method: "POST",
|
||||
headers: { origin, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "login",
|
||||
email,
|
||||
password,
|
||||
next: "/moderation",
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200, "Fixture login failed");
|
||||
fixture.cookie = response.headers
|
||||
.getSetCookie()
|
||||
.map((value) => value.split(";")[0])
|
||||
.join("; ");
|
||||
assert.ok(fixture.cookie, "Login did not set a session cookie");
|
||||
return fixture;
|
||||
}
|
||||
try {
|
||||
const player = await account("contributor"),
|
||||
moderator = await account("moderator"),
|
||||
admin = await account("admin"),
|
||||
missing = randomUUID();
|
||||
const bounds = { minX: 0, minZ: 0, maxX: 64, maxZ: 64 };
|
||||
for (const state of ["review", "unpublished"]) {
|
||||
const map = checked(
|
||||
await db
|
||||
.from("datasets")
|
||||
.insert({
|
||||
owner_id: player.id,
|
||||
name: `Permissions ${state} fixture`,
|
||||
source: "plugin",
|
||||
bounds,
|
||||
state,
|
||||
panorama_count: 5,
|
||||
})
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
maps.push(map.id);
|
||||
checked(
|
||||
await db.from("dataset_inputs").insert({
|
||||
dataset_id: map.id,
|
||||
validated: true,
|
||||
manifest: {
|
||||
format: "minegeo-dataset/v1",
|
||||
minecraftVersion: "1.21.11",
|
||||
dimension: "minecraft:overworld",
|
||||
source: "plugin",
|
||||
name: "Permissions map fixture",
|
||||
description: "Disposable permission verification",
|
||||
serverName: "Fixture server",
|
||||
north: "-z",
|
||||
bounds,
|
||||
viewpoints: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
x: i * 4,
|
||||
y: 64,
|
||||
z: i * 4,
|
||||
})),
|
||||
assets: [
|
||||
{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 1 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
await request(player, "moderation", undefined, "GET", 403);
|
||||
const queue = await request(moderator, "moderation");
|
||||
assert.equal(queue.role, "moderator");
|
||||
assert.deepEqual(queue.reports, []);
|
||||
assert.deepEqual(queue.appeals, []);
|
||||
assert.ok(
|
||||
queue.maps.every(
|
||||
(m) =>
|
||||
m.state === "review" ||
|
||||
(m.state === "published" && m.update_state === "review"),
|
||||
),
|
||||
);
|
||||
assert.ok(queue.maps.some((m) => m.id === maps[0]));
|
||||
assert.ok(!queue.maps.some((m) => m.id === maps[1]));
|
||||
assert.ok(
|
||||
(await request(moderator, `moderation/${maps[0]}/panoramas`)).locations
|
||||
.length === 5,
|
||||
);
|
||||
await request(
|
||||
moderator,
|
||||
`moderation/${maps[1]}/panoramas`,
|
||||
undefined,
|
||||
"GET",
|
||||
403,
|
||||
);
|
||||
for (const actor of [player, moderator]) {
|
||||
for (const [path, body, method] of [
|
||||
["moderation/users", undefined, "GET"],
|
||||
[
|
||||
`moderation/users/${player.id}`,
|
||||
{ prefix: "Spoof", icon: "star" },
|
||||
"PATCH",
|
||||
],
|
||||
["moderation/settings", { betaBadgeEnabled: false }, "PATCH"],
|
||||
[
|
||||
`moderation/appeals/${missing}`,
|
||||
{ decision: "approve", reason: "A valid reason here." },
|
||||
"POST",
|
||||
],
|
||||
[`moderation/reports/${missing}/panoramas`, undefined, "GET"],
|
||||
[
|
||||
`reports/${missing}`,
|
||||
{ decision: "confirm", reason: "A valid reason here." },
|
||||
"PATCH",
|
||||
],
|
||||
["moderation", { datasetId: maps[0], decision: "unpublish" }, "POST"],
|
||||
])
|
||||
await request(actor, path, body, method, 403);
|
||||
}
|
||||
await request(moderator, "moderation", {
|
||||
datasetId: maps[0],
|
||||
decision: "reject",
|
||||
reason: "Returned by the permission verification fixture.",
|
||||
});
|
||||
assert.equal(
|
||||
checked(
|
||||
await db.from("datasets").select("state").eq("id", maps[0]).single(),
|
||||
).state,
|
||||
"rejected",
|
||||
);
|
||||
const full = await request(admin, "moderation");
|
||||
assert.equal(full.role, "admin");
|
||||
assert.ok(full.maps.some((m) => m.id === maps[1]));
|
||||
assert.equal(
|
||||
(await request(admin, `moderation/users?q=${player.id}`)).users[0].id,
|
||||
player.id,
|
||||
);
|
||||
await request(
|
||||
admin,
|
||||
`moderation/users/${player.id}`,
|
||||
{ prefix: "Builder", icon: "gem" },
|
||||
"PATCH",
|
||||
);
|
||||
assert.equal(
|
||||
(await request(player, "profile")).profile.custom_prefix,
|
||||
"Builder",
|
||||
);
|
||||
await request(
|
||||
admin,
|
||||
`moderation/users/${player.id}`,
|
||||
{ prefix: null, icon: "star" },
|
||||
"PATCH",
|
||||
);
|
||||
await request(
|
||||
admin,
|
||||
"moderation/settings",
|
||||
{ betaBadgeEnabled: "invalid" },
|
||||
"PATCH",
|
||||
400,
|
||||
);
|
||||
await request(
|
||||
admin,
|
||||
`reports/${missing}`,
|
||||
{ decision: "dismiss", reason: "A valid reason here." },
|
||||
"PATCH",
|
||||
400,
|
||||
);
|
||||
await request(
|
||||
admin,
|
||||
`moderation/appeals/${missing}`,
|
||||
{ decision: "approve", reason: "A valid reason here." },
|
||||
"POST",
|
||||
400,
|
||||
);
|
||||
for (const actor of [player, moderator, admin])
|
||||
await request(actor, "profile", { role: "admin" }, "PATCH", 400);
|
||||
assert.equal((await request(admin, "profile")).profile.role, "admin");
|
||||
console.log(
|
||||
"Production permissions passed: signed-in players denied, moderator queue restricted, pending panoramas and feedback work, admin controls available, role spoofing denied.",
|
||||
);
|
||||
} finally {
|
||||
if (maps.length)
|
||||
checked(await db.from("review_decisions").delete().in("dataset_id", maps));
|
||||
for (const id of maps)
|
||||
checked(await db.from("datasets").delete().eq("id", id));
|
||||
for (const fixture of accounts) {
|
||||
if (fixture.cookie)
|
||||
await request(fixture, "auth/logout", {}).catch(() => {});
|
||||
checked(await db.from("profiles").delete().eq("id", fixture.id));
|
||||
checked(await db.auth.admin.deleteUser(fixture.id));
|
||||
}
|
||||
console.log("Disposable permission fixtures removed.");
|
||||
}
|
||||
190
scripts/test-admin-permissions-sql.mjs
Normal file
190
scripts/test-admin-permissions-sql.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
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";
|
||||
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((f) => f.endsWith(".sql"))
|
||||
.sort())
|
||||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||
const player = randomUUID(),
|
||||
moderator = randomUUID(),
|
||||
admin = randomUUID(),
|
||||
missing = randomUUID();
|
||||
for (const [id, role] of [
|
||||
[player, "contributor"],
|
||||
[moderator, "moderator"],
|
||||
[admin, "admin"],
|
||||
]) {
|
||||
await db.query("insert into auth.users values($1)", [id]);
|
||||
await db.query("insert into public.profiles(id,role) values($1,$2)", [
|
||||
id,
|
||||
role,
|
||||
]);
|
||||
}
|
||||
const makeMap = async () => {
|
||||
const { rows } = await db.query(
|
||||
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
||||
values($1,'Permissions fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','review',5) returning id`,
|
||||
[player],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
await db.query(
|
||||
"insert into public.dataset_inputs(dataset_id,manifest,validated) values($1,$2,true)",
|
||||
[
|
||||
id,
|
||||
JSON.stringify({
|
||||
name: "Permissions fixture",
|
||||
description: "Role verification",
|
||||
viewpoints: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
assetId: `p${i}`,
|
||||
})),
|
||||
}),
|
||||
],
|
||||
);
|
||||
return id;
|
||||
};
|
||||
const id = await makeMap();
|
||||
const decide = (map, reviewer, decision, reason = null) =>
|
||||
db.query("select public.publish_dataset($1,$2,$3,$4)", [
|
||||
map,
|
||||
reviewer,
|
||||
decision,
|
||||
reason,
|
||||
]);
|
||||
await assert.rejects(
|
||||
decide(id, player, "approve"),
|
||||
/Moderator permission required/,
|
||||
);
|
||||
await decide(id, moderator, "approve");
|
||||
assert.equal(
|
||||
(await db.query("select state from public.datasets where id=$1", [id]))
|
||||
.rows[0].state,
|
||||
"published",
|
||||
);
|
||||
await assert.rejects(
|
||||
decide(id, moderator, "unpublish"),
|
||||
/Admin permission required/,
|
||||
);
|
||||
await decide(id, admin, "unpublish");
|
||||
assert.equal(
|
||||
(await db.query("select state from public.datasets where id=$1", [id]))
|
||||
.rows[0].state,
|
||||
"unpublished",
|
||||
);
|
||||
const rejected = await makeMap();
|
||||
await decide(
|
||||
rejected,
|
||||
moderator,
|
||||
"reject",
|
||||
"Please fix the missing terrain.",
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query("select state from public.datasets where id=$1", [
|
||||
rejected,
|
||||
])
|
||||
).rows[0].state,
|
||||
"rejected",
|
||||
);
|
||||
const updated = await makeMap();
|
||||
await decide(updated, admin, "approve");
|
||||
await db.query(
|
||||
"update public.datasets set update_state='review' where id=$1",
|
||||
[updated],
|
||||
);
|
||||
await decide(updated, moderator, "approve");
|
||||
assert.equal(
|
||||
(
|
||||
await db.query("select update_state from public.datasets where id=$1", [
|
||||
updated,
|
||||
])
|
||||
).rows[0].update_state,
|
||||
null,
|
||||
);
|
||||
for (const actor of [player, moderator]) {
|
||||
await assert.rejects(
|
||||
db.query("select public.set_beta_badge($1,false)", [actor]),
|
||||
/Admin permission required/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("select public.set_user_prefix($1,$2,'Spoof','crown')", [
|
||||
actor,
|
||||
player,
|
||||
]),
|
||||
/Admin permission required/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query(
|
||||
"select public.review_content_report($1,$2,'dismiss','Valid feedback here')",
|
||||
[missing, actor],
|
||||
),
|
||||
/Admin permission required/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query(
|
||||
"select public.review_map_appeal($1,$2,'approve','Valid feedback here')",
|
||||
[missing, actor],
|
||||
),
|
||||
/Admin permission required/,
|
||||
);
|
||||
}
|
||||
await db.query("select public.set_beta_badge($1,false)", [admin]);
|
||||
assert.equal(
|
||||
(await db.query("select beta_badge_enabled from public.site_settings"))
|
||||
.rows[0].beta_badge_enabled,
|
||||
false,
|
||||
);
|
||||
await db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
|
||||
admin,
|
||||
player,
|
||||
]);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query("select custom_prefix from public.profiles where id=$1", [
|
||||
player,
|
||||
])
|
||||
).rows[0].custom_prefix,
|
||||
"Builder",
|
||||
);
|
||||
await db.exec(
|
||||
"create role identity_probe bypassrls; grant select,update on public.profiles to identity_probe; grant usage on schema public to identity_probe; set role identity_probe",
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("update public.profiles set role='admin' where id=$1", [player]),
|
||||
/Moderator permission required/,
|
||||
);
|
||||
await db.exec("reset role");
|
||||
for (const role of ["anon", "authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
for (const query of [
|
||||
[
|
||||
"select public.publish_dataset($1,$2,'unpublish',null)",
|
||||
[updated, admin],
|
||||
],
|
||||
["select public.set_beta_badge($1,true)", [admin]],
|
||||
["select public.set_user_prefix($1,$2,'Spoof','star')", [admin, player]],
|
||||
[
|
||||
"select public.review_content_report($1,$2,'dismiss','Valid feedback here')",
|
||||
[missing, admin],
|
||||
],
|
||||
[
|
||||
"select public.review_map_appeal($1,$2,'approve','Valid feedback here')",
|
||||
[missing, admin],
|
||||
],
|
||||
])
|
||||
await assert.rejects(db.query(...query), /permission denied/);
|
||||
await db.exec("reset role");
|
||||
}
|
||||
console.log(
|
||||
"Admin permissions SQL passed: moderators approve/reject maps and updates; administrative actions and role spoofing are blocked; admins retain full access.",
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
@@ -167,16 +167,15 @@ try {
|
||||
);
|
||||
const mod = randomUUID();
|
||||
await db.query("insert into auth.users values($1)", [mod]);
|
||||
await db.query(
|
||||
"insert into public.profiles(id,role) values($1,'moderator')",
|
||||
[mod],
|
||||
);
|
||||
await db.query("insert into public.profiles(id,role) values($1,'admin')", [
|
||||
mod,
|
||||
]);
|
||||
await assert.rejects(
|
||||
db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
|
||||
account,
|
||||
mod,
|
||||
]),
|
||||
/Moderator permission/,
|
||||
/Admin permission/,
|
||||
);
|
||||
await db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
|
||||
mod,
|
||||
@@ -205,6 +204,7 @@ try {
|
||||
await db.exec("set role reward_probe");
|
||||
for (const column of [
|
||||
"role='moderator'",
|
||||
"role='admin'",
|
||||
"bug_finder=false",
|
||||
"custom_prefix='Spoof'",
|
||||
"custom_prefix_icon='crown'",
|
||||
|
||||
184
scripts/test-discord-http.mjs
Normal file
184
scripts/test-discord-http.mjs
Normal file
@@ -0,0 +1,184 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID, randomBytes } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
process.loadEnvFile(".env.local");
|
||||
const origin = process.argv[2] || "https://minegeo.vercel.app";
|
||||
const db = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.SUPABASE_SECRET_KEY,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
const accounts = [],
|
||||
rooms = [];
|
||||
const checked = (r) => {
|
||||
if (r.error) throw new Error(r.error.message);
|
||||
return r.data;
|
||||
};
|
||||
await mkdir(".data/discord-check", { recursive: true });
|
||||
const journal = () =>
|
||||
writeFile(
|
||||
".data/discord-check/http-fixtures.json",
|
||||
JSON.stringify({ accounts, rooms }),
|
||||
);
|
||||
function session() {
|
||||
const cookies = new Map();
|
||||
return async (
|
||||
path,
|
||||
body,
|
||||
method = body === undefined ? "GET" : "POST",
|
||||
expected = 200,
|
||||
) => {
|
||||
const response = await fetch(`${origin}/api/v1/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
origin,
|
||||
"content-type": "application/json",
|
||||
cookie: [...cookies]
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("; "),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
for (const cookie of response.headers.getSetCookie()) {
|
||||
const part = cookie.split(";")[0],
|
||||
at = part.indexOf("=");
|
||||
cookies.set(part.slice(0, at), part.slice(at + 1));
|
||||
}
|
||||
const data = await response.json();
|
||||
assert.equal(response.status, expected, `${path}: ${JSON.stringify(data)}`);
|
||||
assert.match(response.headers.get("cache-control") ?? "", /no-store/);
|
||||
return data;
|
||||
};
|
||||
}
|
||||
async function account(role) {
|
||||
const email = `discord-check-${randomUUID()}@example.com`,
|
||||
password = randomBytes(24).toString("base64url");
|
||||
const { user } = checked(
|
||||
await db.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: { display_name: `Discord ${role} fixture` },
|
||||
}),
|
||||
);
|
||||
accounts.push(user.id);
|
||||
await journal();
|
||||
checked(
|
||||
await db
|
||||
.from("profiles")
|
||||
.insert({ id: user.id, role, display_name: `Discord ${role} fixture` }),
|
||||
);
|
||||
const api = session();
|
||||
await api("auth", { action: "login", email, password, next: "/moderation" });
|
||||
return api;
|
||||
}
|
||||
try {
|
||||
const admin = await account("admin"),
|
||||
moderator = await account("moderator"),
|
||||
visitor = session();
|
||||
const status = await admin("moderation/discord");
|
||||
assert.equal(typeof status.botConfigured, "boolean");
|
||||
assert.equal(typeof status.pending, "number");
|
||||
assert.ok(
|
||||
!Object.keys(status).some((key) => /token|secret|channel_?id/i.test(key)),
|
||||
);
|
||||
await visitor("moderation/discord", undefined, "GET", 401);
|
||||
for (const [body, method] of [
|
||||
[undefined, "GET"],
|
||||
[{ action: "check" }, "POST"],
|
||||
[{ action: "retry" }, "POST"],
|
||||
])
|
||||
await moderator("moderation/discord", body, method, 403);
|
||||
if (
|
||||
!status.botConfigured ||
|
||||
!status.publicUrlConfigured ||
|
||||
(!status.mapsConfigured && !status.partiesConfigured)
|
||||
)
|
||||
await admin("moderation/discord", { action: "check" }, "POST", 400);
|
||||
const dispatch = await fetch(`${origin}/api/integrations/discord/dispatch`);
|
||||
assert.equal(dispatch.status, 401);
|
||||
console.log(
|
||||
"Discord HTTP: admin-only setup, secret-free status, disabled configuration and private dispatch verified.",
|
||||
);
|
||||
const dataset = checked(
|
||||
await db
|
||||
.from("datasets")
|
||||
.select("id")
|
||||
.eq("state", "published")
|
||||
.is("parent_dataset_id", null)
|
||||
.is("imported_from_id", null)
|
||||
.limit(1)
|
||||
.single(),
|
||||
);
|
||||
const host = session(),
|
||||
friend = session(),
|
||||
outsider = session();
|
||||
const created = await host("rooms", {
|
||||
datasetId: dataset.id,
|
||||
difficulty: "normal",
|
||||
displayName: "Discord host fixture",
|
||||
playerLimit: 4,
|
||||
});
|
||||
rooms.push(created.id);
|
||||
await journal();
|
||||
const room = await host(`rooms/${created.id}`);
|
||||
assert.equal(typeof room.discord.available, "boolean");
|
||||
assert.equal(room.discord.state, null);
|
||||
await outsider(`rooms/${created.id}/discord`, {}, "POST", 403);
|
||||
await friend(`rooms/${created.id}/join`, {
|
||||
displayName: "Discord friend fixture",
|
||||
});
|
||||
assert.equal((await friend(`rooms/${created.id}`)).discord, undefined);
|
||||
await friend(`rooms/${created.id}/discord`, {}, "POST", 403);
|
||||
await host(
|
||||
`rooms/${created.id}/discord`,
|
||||
{ channelId: "123456789012345678" },
|
||||
"POST",
|
||||
400,
|
||||
);
|
||||
if (
|
||||
!status.botConfigured ||
|
||||
!status.publicUrlConfigured ||
|
||||
!status.partiesConfigured
|
||||
) {
|
||||
assert.equal(room.discord.available, false);
|
||||
await host(`rooms/${created.id}/discord`, {}, "POST", 503);
|
||||
}
|
||||
assert.equal(
|
||||
checked(
|
||||
await db
|
||||
.from("discord_announcements")
|
||||
.select("id")
|
||||
.eq("kind", "party")
|
||||
.eq("target_id", created.id),
|
||||
).length,
|
||||
0,
|
||||
"disabled and unauthorized actions never queue a notification",
|
||||
);
|
||||
await host(`rooms/${created.id}/close`, {});
|
||||
console.log(
|
||||
"Discord HTTP passed: guests and members cannot publish, destination overrides denied, unconfigured hosts get an actionable error, no test Discord messages sent.",
|
||||
);
|
||||
} finally {
|
||||
for (const id of rooms) {
|
||||
checked(
|
||||
await db
|
||||
.from("discord_announcements")
|
||||
.delete()
|
||||
.eq("kind", "party")
|
||||
.eq("target_id", id),
|
||||
);
|
||||
checked(await db.from("multiplayer_rooms").delete().eq("id", id));
|
||||
}
|
||||
for (const id of accounts) {
|
||||
checked(await db.from("profiles").delete().eq("id", id));
|
||||
checked(await db.auth.admin.deleteUser(id));
|
||||
}
|
||||
await writeFile(
|
||||
".data/discord-check/http-fixtures.json",
|
||||
JSON.stringify({ accounts: [], rooms: [] }),
|
||||
);
|
||||
console.log("Discord HTTP fixtures removed.");
|
||||
}
|
||||
391
scripts/test-discord-sql.mjs
Normal file
391
scripts/test-discord-sql.mjs
Normal file
@@ -0,0 +1,391 @@
|
||||
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";
|
||||
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((f) => f.endsWith(".sql"))
|
||||
.sort())
|
||||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||
const owner = randomUUID(),
|
||||
mod = randomUUID(),
|
||||
admin = randomUUID();
|
||||
for (const [id, role] of [
|
||||
[owner, "contributor"],
|
||||
[mod, "moderator"],
|
||||
[admin, "admin"],
|
||||
]) {
|
||||
await db.query("insert into auth.users values($1)", [id]);
|
||||
await db.query("insert into public.profiles(id,role) values($1,$2)", [
|
||||
id,
|
||||
role,
|
||||
]);
|
||||
}
|
||||
const manifest = {
|
||||
name: "Discord fixture",
|
||||
description: "Notification verification",
|
||||
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
|
||||
viewpoints: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
x: i,
|
||||
y: 64,
|
||||
z: i,
|
||||
})),
|
||||
};
|
||||
const map = (
|
||||
await db.query(
|
||||
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count) values($1,'Discord fixture','plugin',$2,'review',5) returning id`,
|
||||
[owner, JSON.stringify(manifest.bounds)],
|
||||
)
|
||||
).rows[0].id;
|
||||
await db.query(
|
||||
"insert into public.dataset_inputs(dataset_id,manifest,validated) values($1,$2,true)",
|
||||
[map, JSON.stringify(manifest)],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select count(*)::int as n from public.discord_announcements",
|
||||
)
|
||||
).rows[0].n,
|
||||
0,
|
||||
);
|
||||
await db.query("select public.publish_dataset($1,$2,'approve',null)", [
|
||||
map,
|
||||
mod,
|
||||
]);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select count(*)::int as n from public.discord_announcements where kind='map' and target_id=$1",
|
||||
[map],
|
||||
)
|
||||
).rows[0].n,
|
||||
1,
|
||||
);
|
||||
await db.query(
|
||||
"update public.datasets set update_state='review' where id=$1",
|
||||
[map],
|
||||
);
|
||||
await db.query("select public.publish_dataset($1,$2,'approve',null)", [
|
||||
map,
|
||||
mod,
|
||||
]);
|
||||
await db.query("select public.publish_dataset($1,$2,'unpublish',null)", [
|
||||
map,
|
||||
admin,
|
||||
]);
|
||||
await db.query("update public.datasets set state='review' where id=$1", [
|
||||
map,
|
||||
]);
|
||||
await db.query("select public.publish_dataset($1,$2,'approve',null)", [
|
||||
map,
|
||||
admin,
|
||||
]);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select count(*)::int as n from public.discord_announcements",
|
||||
)
|
||||
).rows[0].n,
|
||||
1,
|
||||
"updates and re-publication do not spam new-map announcements",
|
||||
);
|
||||
const hash = "a".repeat(64),
|
||||
friend = "b".repeat(64),
|
||||
outsider = "c".repeat(64);
|
||||
const create = async () =>
|
||||
(
|
||||
await db.query(
|
||||
"select public.create_map_room($1,'normal','Host fixture',4,$2,$3) as id",
|
||||
[map, hash, owner],
|
||||
)
|
||||
).rows[0].id;
|
||||
const room = await create();
|
||||
await db.query(
|
||||
"select public.multiplayer_action($1,$2,'join','{\"name\":\"Friend fixture\"}')",
|
||||
[room, friend],
|
||||
);
|
||||
for (const secret of [friend, outsider])
|
||||
await assert.rejects(
|
||||
db.query("select public.publish_discord_party($1,$2)", [room, secret]),
|
||||
/Only the room owner/,
|
||||
);
|
||||
const event = (
|
||||
await db.query("select public.publish_discord_party($1,$2) as id", [
|
||||
room,
|
||||
hash,
|
||||
])
|
||||
).rows[0].id;
|
||||
assert.equal(
|
||||
(
|
||||
await db.query("select public.publish_discord_party($1,$2) as id", [
|
||||
room,
|
||||
hash,
|
||||
])
|
||||
).rows[0].id,
|
||||
event,
|
||||
"repeated host clicks return the same event",
|
||||
);
|
||||
const claimed = (
|
||||
await db.query(
|
||||
"select * from public.claim_discord_announcements('first',array['party'],null)",
|
||||
)
|
||||
).rows;
|
||||
assert.equal(claimed.length, 1);
|
||||
assert.equal(claimed[0].attempts, 1);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select * from public.claim_discord_announcements('second',array['party'],null)",
|
||||
)
|
||||
).rows.length,
|
||||
0,
|
||||
"active leases prevent double sends",
|
||||
);
|
||||
await db.query(
|
||||
"update public.discord_announcements set lease_until=clock_timestamp()-interval '1 second' where id=$1",
|
||||
[event],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select * from public.claim_discord_announcements('second',array['party'],null)",
|
||||
)
|
||||
).rows[0].attempts,
|
||||
2,
|
||||
"expired workers recover",
|
||||
);
|
||||
await db.query(
|
||||
"update public.discord_announcements set attempts=8,lease_until=clock_timestamp()-interval '1 second' where id=$1",
|
||||
[event],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select * from public.claim_discord_announcements('third',array['party'],null)",
|
||||
)
|
||||
).rows.length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select state from public.discord_announcements where id=$1",
|
||||
[event],
|
||||
)
|
||||
).rows[0].state,
|
||||
"failed",
|
||||
"exhausted expired leases become actionable failures",
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("select public.retry_discord_announcements($1)", [mod]),
|
||||
/Admin permission/,
|
||||
);
|
||||
await db.query("select public.retry_discord_announcements($1)", [admin]);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select state from public.discord_announcements where id=$1",
|
||||
[event],
|
||||
)
|
||||
).rows[0].state,
|
||||
"pending",
|
||||
);
|
||||
const full = await create();
|
||||
for (let i = 0; i < 3; i++)
|
||||
await db.query("select public.multiplayer_action($1,$2,'join',$3)", [
|
||||
full,
|
||||
String(i + 4).repeat(64),
|
||||
JSON.stringify({ name: `Player ${i}` }),
|
||||
]);
|
||||
await assert.rejects(
|
||||
db.query("select public.publish_discord_party($1,$2)", [full, hash]),
|
||||
/room is full/,
|
||||
);
|
||||
const expired = await create();
|
||||
await db.query(
|
||||
"update public.multiplayer_rooms set created_at=clock_timestamp()-interval '25 hours' where id=$1",
|
||||
[expired],
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("select public.publish_discord_party($1,$2)", [expired, hash]),
|
||||
/Only open lobbies/,
|
||||
);
|
||||
await db.query(
|
||||
"update public.multiplayer_rooms set status='closed' where id=$1",
|
||||
[room],
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("select public.publish_discord_party($1,$2)", [room, hash]),
|
||||
/Only open lobbies/,
|
||||
);
|
||||
await db.query(
|
||||
"update public.discord_announcements set state='sent',channel_id='123456789012345678',message_id='223456789012345678' where id=$1",
|
||||
[event],
|
||||
);
|
||||
let cleanup = (
|
||||
await db.query(
|
||||
"select * from public.claim_discord_cleanup('starting:first',$1)",
|
||||
[room],
|
||||
)
|
||||
).rows;
|
||||
assert.equal(
|
||||
cleanup.length,
|
||||
1,
|
||||
"a send acknowledged after closure is cleaned up",
|
||||
);
|
||||
assert.ok(cleanup[0].closed_at);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select * from public.claim_discord_cleanup('starting:second',$1)",
|
||||
[room],
|
||||
)
|
||||
).rows.length,
|
||||
0,
|
||||
"cleanup scheduling has a lease",
|
||||
);
|
||||
const deadline = (
|
||||
await db.query("select public.mark_discord_party_closed($1) as deadline", [
|
||||
event,
|
||||
])
|
||||
).rows[0].deadline;
|
||||
assert.ok(
|
||||
Math.abs(new Date(deadline).getTime() - Date.now() - 300000) < 5000,
|
||||
"red notice gets five minutes",
|
||||
);
|
||||
assert.equal(
|
||||
String(
|
||||
(
|
||||
await db.query(
|
||||
"select public.mark_discord_party_closed($1) as deadline",
|
||||
[event],
|
||||
)
|
||||
).rows[0].deadline,
|
||||
),
|
||||
String(deadline),
|
||||
"retry never extends deletion deadline",
|
||||
);
|
||||
await db.query(
|
||||
"update public.discord_announcements set cleanup_run_id='run_active',cleanup_lease_until=null where id=$1",
|
||||
[event],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select * from public.claim_discord_cleanup('starting:third',$1)",
|
||||
[room],
|
||||
)
|
||||
).rows.length,
|
||||
0,
|
||||
"durable workflow remains scheduled beyond the lease",
|
||||
);
|
||||
await db.query(
|
||||
"update public.discord_announcements set deleted_at=clock_timestamp(),cleanup_run_id=null where id=$1",
|
||||
[event],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select * from public.claim_discord_cleanup('starting:fourth',$1)",
|
||||
[room],
|
||||
)
|
||||
).rows.length,
|
||||
0,
|
||||
"removed messages stay removed",
|
||||
);
|
||||
const closedSentRoom = await create();
|
||||
const closedSentEvent = (
|
||||
await db.query("select public.publish_discord_party($1,$2) as id", [
|
||||
closedSentRoom,
|
||||
hash,
|
||||
])
|
||||
).rows[0].id;
|
||||
await db.query(
|
||||
"update public.discord_announcements set state='sent',channel_id='123456789012345678',message_id='223456789012345679' where id=$1",
|
||||
[closedSentEvent],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select * from public.claim_discord_cleanup('starting:open',$1)",
|
||||
[closedSentRoom],
|
||||
)
|
||||
).rows.length,
|
||||
0,
|
||||
"open rooms are untouched",
|
||||
);
|
||||
await db.query("select public.multiplayer_action($1,$2,'leave','{}')", [
|
||||
closedSentRoom,
|
||||
hash,
|
||||
]);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select * from public.claim_discord_cleanup('starting:leave',$1)",
|
||||
[closedSentRoom],
|
||||
)
|
||||
).rows.length,
|
||||
1,
|
||||
"last-player departure queues cleanup",
|
||||
);
|
||||
const hiddenRoom = await create();
|
||||
await db.query("select public.publish_dataset($1,$2,'unpublish',null)", [
|
||||
map,
|
||||
admin,
|
||||
]);
|
||||
await assert.rejects(
|
||||
db.query("select public.publish_discord_party($1,$2)", [hiddenRoom, hash]),
|
||||
/Only parties on published maps/,
|
||||
);
|
||||
const imported = (
|
||||
await db.query(
|
||||
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count,imported_from_id,imported_owner_name) values($1,'Imported fixture','plugin',$2,'draft',5,$3,'Original owner') returning id`,
|
||||
[owner, JSON.stringify(manifest.bounds), map],
|
||||
)
|
||||
).rows[0].id;
|
||||
await db.query(
|
||||
"insert into public.dataset_inputs(dataset_id,manifest,validated) values($1,$2,true)",
|
||||
[imported, JSON.stringify(manifest)],
|
||||
);
|
||||
const privateRoom = (
|
||||
await db.query(
|
||||
"select public.create_map_room($1,'normal','Host fixture',4,$2,$3) as id",
|
||||
[imported, hash, owner],
|
||||
)
|
||||
).rows[0].id;
|
||||
await assert.rejects(
|
||||
db.query("select public.publish_discord_party($1,$2)", [privateRoom, hash]),
|
||||
/Only parties on published maps/,
|
||||
);
|
||||
for (const role of ["anon", "authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
await assert.rejects(
|
||||
db.query("select * from public.discord_announcements"),
|
||||
/permission denied/,
|
||||
);
|
||||
for (const query of [
|
||||
["select public.publish_discord_party($1,$2)", [room, hash]],
|
||||
[
|
||||
"select * from public.claim_discord_announcements('spoof',array['map'],null)",
|
||||
[],
|
||||
],
|
||||
["select public.retry_discord_announcements($1)", [admin]],
|
||||
["select * from public.claim_discord_cleanup('spoof',null)", []],
|
||||
["select public.mark_discord_party_closed($1)", [event]],
|
||||
])
|
||||
await assert.rejects(db.query(...query), /permission denied/);
|
||||
await db.exec("reset role");
|
||||
}
|
||||
console.log(
|
||||
"Discord SQL passed: first-publication events, one post per party, host authorization, private-map isolation, channel-independent queue, leases, retries, expiry, capacity and server-only access.",
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
@@ -70,7 +70,7 @@ async function account(role) {
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
display_name:
|
||||
role === "moderator" ? "Prefix moderator test" : "Badge player test",
|
||||
role === "admin" ? "Prefix admin test" : "Badge player test",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -81,7 +81,7 @@ async function account(role) {
|
||||
id,
|
||||
role,
|
||||
display_name:
|
||||
role === "moderator" ? "Prefix moderator test" : "Badge player test",
|
||||
role === "admin" ? "Prefix admin test" : "Badge player test",
|
||||
}),
|
||||
);
|
||||
const context = await browser.newContext({ locale: "en-US" });
|
||||
@@ -98,7 +98,7 @@ try {
|
||||
await mkdir(".data/support-deploy", { recursive: true });
|
||||
await phase("starting");
|
||||
const player = await account("contributor"),
|
||||
mod = await account("moderator");
|
||||
mod = await account("admin");
|
||||
await api(player.context, "profile", { glowColor: "#54baff" }, "PATCH", 403);
|
||||
await api(
|
||||
player.context,
|
||||
@@ -118,7 +118,7 @@ try {
|
||||
await player.page.goto(origin + "/settings", { waitUntil: "networkidle" });
|
||||
assert.equal(await player.page.locator("input[type=color]").count(), 0);
|
||||
await mod.page.goto(origin + "/settings", { waitUntil: "networkidle" });
|
||||
assert.ok((await mod.page.locator(".username-moderator svg").count()) > 0);
|
||||
assert.ok((await mod.page.locator(".username-admin svg").count()) > 0);
|
||||
await mod.page.goto(origin + "/moderation", { waitUntil: "networkidle" });
|
||||
await mod.page
|
||||
.getByLabel("Player name or account ID", { exact: true })
|
||||
@@ -343,7 +343,7 @@ try {
|
||||
);
|
||||
await phase("passed");
|
||||
console.log(
|
||||
"Profile appearance production passed: moderator-only prefixes, removal, automatic badges, real webhook grant/revoke, persistent color, authenticated multiplayer/chat, guest spoof prevention and mobile layouts.",
|
||||
"Profile appearance production passed: admin-only prefixes, removal, automatic badges, real webhook grant/revoke, persistent color, authenticated multiplayer/chat, guest spoof prevention and mobile layouts.",
|
||||
);
|
||||
} catch (error) {
|
||||
for (const context of contexts)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||
|
||||
const db = new PGlite();
|
||||
try {
|
||||
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
|
||||
create table public.profiles(id uuid primary key, role text not null);
|
||||
grant select on public.profiles to service_role;
|
||||
insert into public.profiles values
|
||||
('00000000-0000-4000-8000-000000000001', 'moderator'),
|
||||
('00000000-0000-4000-8000-000000000002', 'contributor');`);
|
||||
await db.exec(
|
||||
await readFile(
|
||||
"supabase/migrations/20261006141532_site_settings.sql",
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
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((f) => f.endsWith(".sql"))
|
||||
.sort())
|
||||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||
await db.exec(`insert into auth.users values
|
||||
('00000000-0000-4000-8000-000000000001'),('00000000-0000-4000-8000-000000000002');
|
||||
insert into public.profiles(id,role) values
|
||||
('00000000-0000-4000-8000-000000000001','admin'),('00000000-0000-4000-8000-000000000002','contributor');`);
|
||||
const flag = async () =>
|
||||
(await db.query("select beta_badge_enabled from public.site_settings"))
|
||||
.rows[0].beta_badge_enabled;
|
||||
@@ -39,11 +39,11 @@ try {
|
||||
db.query(
|
||||
"select public.set_beta_badge('00000000-0000-4000-8000-000000000002', false)",
|
||||
),
|
||||
/Moderator permission required/,
|
||||
/Admin permission required/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("select public.set_beta_badge(null, false)"),
|
||||
/Moderator permission required/,
|
||||
/Admin permission required/,
|
||||
);
|
||||
assert.equal(await flag(), true);
|
||||
await db.query(
|
||||
@@ -66,7 +66,7 @@ try {
|
||||
/visibility is required/,
|
||||
);
|
||||
console.log(
|
||||
"Site settings SQL passed: guests and players denied; moderators can hide and restore; updates persist and record the moderator.",
|
||||
"Site settings SQL passed: guests and players denied; admins can hide and restore; updates persist and record the admin.",
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
|
||||
Reference in New Issue
Block a user