Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { NextRequest } from "next/server";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import type { Database } from "../shared/database";
|
|
import { communityApi } from "../lib/community-api";
|
|
import {
|
|
commentInput,
|
|
communityReportInput,
|
|
reactionInput,
|
|
} from "../shared/community";
|
|
import { ServiceError } from "../lib/service-error";
|
|
|
|
const map = "11111111-1111-4111-8111-111111111111";
|
|
const member = "22222222-2222-4222-8222-222222222222";
|
|
const comment = "33333333-3333-4333-8333-333333333333";
|
|
const other = "44444444-4444-4444-8444-444444444444";
|
|
|
|
test("community cursors advance by timestamp and ID even if earlier rows disappear", async () => {
|
|
const created = "2026-10-17T00:00:00.000Z";
|
|
const cursor = encodeURIComponent(`${created}|${comment}`);
|
|
const f = fixture({ signedIn: false });
|
|
f.reply("datasets", { id: map });
|
|
f.reply("map_reaction_summary", [{ likes: 0, dislikes: 0 }]);
|
|
f.reply("map_comments", []);
|
|
await f.run(`maps/${map}/community?cursor=${cursor}`);
|
|
assert.ok(
|
|
f.calls.some(
|
|
(c) =>
|
|
c.method === "or" &&
|
|
c.args[0] ===
|
|
`created_at.lt.${created},and(created_at.eq.${created},id.lt.${comment})`,
|
|
),
|
|
);
|
|
assert.ok(!f.calls.some((c) => c.method === "range"));
|
|
const staff = fixture({ role: "moderator" });
|
|
staff.reply("community_reports", []);
|
|
await staff.run(`moderation/community-reports?cursor=${cursor}`);
|
|
assert.ok(
|
|
staff.calls.some(
|
|
(c) =>
|
|
c.method === "or" &&
|
|
c.args[0] ===
|
|
`created_at.gt.${created},and(created_at.eq.${created},id.gt.${comment})`,
|
|
),
|
|
);
|
|
const invalid = fixture({ signedIn: false });
|
|
invalid.reply("datasets", { id: map });
|
|
await assert.rejects(
|
|
invalid.run(`maps/${map}/community?cursor=invalid|${comment}`),
|
|
);
|
|
assert.ok(!invalid.calls.some((c) => c.method === "or"));
|
|
});
|
|
type Result = {
|
|
data: unknown;
|
|
error: { message: string; code?: string } | null;
|
|
};
|
|
function fixture(
|
|
options: { signedIn?: boolean; role?: string; inactive?: boolean } = {},
|
|
) {
|
|
const calls: { table: string; method: string; args: unknown[] }[] = [];
|
|
const replies = new Map<string, Result[]>();
|
|
const rates: string[] = [];
|
|
const db = {
|
|
from(table: string) {
|
|
const query: Record<string, unknown> = {};
|
|
for (const method of [
|
|
"select",
|
|
"insert",
|
|
"update",
|
|
"delete",
|
|
"upsert",
|
|
"eq",
|
|
"is",
|
|
"in",
|
|
"order",
|
|
"range",
|
|
"or",
|
|
"limit",
|
|
"single",
|
|
"maybeSingle",
|
|
])
|
|
query[method] = (...args: unknown[]) => {
|
|
calls.push({ table, method, args });
|
|
return query;
|
|
};
|
|
query.then = (resolve: (value: Result) => unknown) =>
|
|
Promise.resolve(
|
|
replies.get(table)?.shift() ?? { data: null, error: null },
|
|
).then(resolve);
|
|
return query;
|
|
},
|
|
rpc(name: string, args: unknown) {
|
|
calls.push({ table: name, method: "rpc", args: [args] });
|
|
return Promise.resolve(
|
|
replies.get(name)?.shift() ?? { data: [], error: null },
|
|
);
|
|
},
|
|
} as unknown as SupabaseClient<Database>;
|
|
return {
|
|
calls,
|
|
rates,
|
|
reply(table: string, data: unknown, error: Result["error"] = null) {
|
|
const list = replies.get(table) ?? [];
|
|
list.push({ data, error });
|
|
replies.set(table, list);
|
|
},
|
|
run(path: string, method = "GET", body?: unknown, authorization?: string) {
|
|
const headers = new Headers();
|
|
if (authorization !== undefined)
|
|
headers.set("authorization", authorization);
|
|
return communityApi(
|
|
new NextRequest("https://minegeo.test/api/v1/" + path, {
|
|
method,
|
|
headers,
|
|
}),
|
|
path.split("?")[0].split("/"),
|
|
{
|
|
db,
|
|
getUser: async () => {
|
|
if (options.inactive) throw new ServiceError("Account banned", 403);
|
|
return options.signedIn === false ? null : { id: member };
|
|
},
|
|
getProfile: async () => ({
|
|
id: member,
|
|
role: options.role ?? "contributor",
|
|
}),
|
|
jsonBody: async () => body,
|
|
limit: async (key) => {
|
|
rates.push(key);
|
|
},
|
|
},
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
test("community validation rejects blank/oversized comments, forged authors, and invalid reactions", () => {
|
|
assert.equal(
|
|
commentInput.parse({ body: " A lovely map! " }).body,
|
|
"A lovely map!",
|
|
);
|
|
for (const body of [" ", "x".repeat(2001)])
|
|
assert.equal(commentInput.safeParse({ body }).success, false);
|
|
assert.equal(
|
|
commentInput.safeParse({ body: "Hello", author_id: other }).success,
|
|
false,
|
|
);
|
|
for (const value of [-1, 0, 1])
|
|
assert.equal(reactionInput.safeParse({ value }).success, true);
|
|
for (const value of [2, "1", null])
|
|
assert.equal(reactionInput.safeParse({ value }).success, false);
|
|
for (const category of [
|
|
"spam",
|
|
"advertising",
|
|
"inappropriate",
|
|
"harassment",
|
|
"other",
|
|
])
|
|
assert.equal(
|
|
communityReportInput.safeParse({
|
|
target: "account",
|
|
targetId: other,
|
|
category,
|
|
reason: "Enough detail for staff.",
|
|
}).success,
|
|
true,
|
|
);
|
|
for (const extra of [
|
|
{ reporter_id: other },
|
|
{ state: "resolved" },
|
|
{ target: "map" },
|
|
{ category: "unknown" },
|
|
{ reason: "short" },
|
|
])
|
|
assert.equal(
|
|
communityReportInput.safeParse({
|
|
target: "comment",
|
|
targetId: comment,
|
|
category: "spam",
|
|
reason: "Enough detail for staff.",
|
|
...extra,
|
|
}).success,
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("guests, device tokens, and restricted accounts cannot create community content", async () => {
|
|
for (const [path, method, body] of [
|
|
[`maps/${map}/comments`, "POST", { body: "Hello" }],
|
|
[`maps/${map}/reaction`, "POST", { value: 1 }],
|
|
[
|
|
"community/reports",
|
|
"POST",
|
|
{
|
|
target: "account",
|
|
targetId: other,
|
|
category: "spam",
|
|
reason: "Enough detail for staff.",
|
|
},
|
|
],
|
|
] as const) {
|
|
const guest = fixture({ signedIn: false });
|
|
await assert.rejects(
|
|
guest.run(path, method, body),
|
|
(e: unknown) => e instanceof ServiceError && e.status === 401,
|
|
);
|
|
assert.equal(guest.calls.length, 0);
|
|
const device = fixture();
|
|
await assert.rejects(
|
|
device.run(path, method, body, "Bearer upload-token"),
|
|
(e: unknown) => e instanceof ServiceError && e.status === 403,
|
|
);
|
|
assert.equal(device.calls.length, 0);
|
|
await assert.rejects(
|
|
fixture({ inactive: true }).run(path, method, body),
|
|
/banned/,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("regular users cannot remove comments, read reports, or review reports", async () => {
|
|
for (const [path, method] of [
|
|
[`community/comments/${comment}`, "DELETE"],
|
|
["moderation/community-reports", "GET"],
|
|
[`moderation/community-reports/${comment}`, "PATCH"],
|
|
]) {
|
|
const f = fixture();
|
|
await assert.rejects(
|
|
f.run(path, method),
|
|
(e: unknown) => e instanceof ServiceError && e.status === 403,
|
|
);
|
|
assert.equal(f.calls.length, 0);
|
|
}
|
|
});
|
|
|
|
test("reaction switches use an account-bound upsert and clearing affects only that user's map reaction", async () => {
|
|
for (const value of [1, -1, 0]) {
|
|
const f = fixture();
|
|
f.reply("datasets", { id: map });
|
|
f.reply("map_reactions", null);
|
|
assert.equal(
|
|
(await f.run(`maps/${map}/reaction`, "POST", { value })).status,
|
|
200,
|
|
);
|
|
assert.deepEqual(f.rates, ["community:reaction:" + member]);
|
|
if (value === 0) {
|
|
assert.ok(
|
|
f.calls.some(
|
|
(c) =>
|
|
c.table === "map_reactions" &&
|
|
c.method === "eq" &&
|
|
c.args[0] === "user_id" &&
|
|
c.args[1] === member,
|
|
),
|
|
);
|
|
} else
|
|
assert.deepEqual(f.calls.find((c) => c.method === "upsert")?.args, [
|
|
{ dataset_id: map, user_id: member, value },
|
|
{ onConflict: "dataset_id,user_id" },
|
|
]);
|
|
}
|
|
});
|
|
|
|
test("comment publishing derives the author from the session and rejects unavailable maps", async () => {
|
|
const f = fixture();
|
|
f.reply("datasets", { id: map });
|
|
f.reply("map_comments", { id: comment });
|
|
assert.equal(
|
|
(await f.run(`maps/${map}/comments`, "POST", { body: " Great map " }))
|
|
.status,
|
|
201,
|
|
);
|
|
assert.deepEqual(f.calls.find((c) => c.method === "insert")?.args, [
|
|
{ dataset_id: map, author_id: member, body: "Great map" },
|
|
]);
|
|
assert.deepEqual(f.rates, ["community:comment:" + member]);
|
|
const missing = fixture();
|
|
await assert.rejects(
|
|
missing.run(`maps/${map}/comments`, "POST", { body: "Hello" }),
|
|
/not found/,
|
|
);
|
|
assert.ok(!missing.calls.some((c) => c.method === "insert"));
|
|
});
|
|
|
|
test("public comment reads filter removed content and return no reviewer or reporter data", async () => {
|
|
const f = fixture({ signedIn: false });
|
|
f.reply("datasets", { id: map });
|
|
f.reply("map_reaction_summary", [{ likes: 5, dislikes: 2 }]);
|
|
f.reply("map_comments", [
|
|
{
|
|
id: comment,
|
|
author_id: null,
|
|
body: "Still here",
|
|
created_at: "2026-10-17T00:00:00Z",
|
|
},
|
|
]);
|
|
const data = await (await f.run(`maps/${map}/community`)).json();
|
|
assert.equal(data.viewer, null);
|
|
assert.equal(data.reaction, 0);
|
|
assert.equal(data.likes, 5);
|
|
assert.equal(data.dislikes, 2);
|
|
assert.equal(data.comments[0].author, "Deleted player");
|
|
assert.ok(
|
|
f.calls.some(
|
|
(c) =>
|
|
c.method === "is" && c.args[0] === "removed_at" && c.args[1] === null,
|
|
),
|
|
);
|
|
assert.ok(!f.calls.some((c) => c.table === "community_reports"));
|
|
});
|
|
|
|
test("staff removal records reviewer and timestamp without destroying evidence", async () => {
|
|
for (const role of ["moderator", "admin"]) {
|
|
const f = fixture({ role });
|
|
f.reply("map_comments", { id: comment });
|
|
assert.equal(
|
|
(await f.run(`community/comments/${comment}`, "DELETE")).status,
|
|
200,
|
|
);
|
|
const update = f.calls.find((c) => c.method === "update")?.args[0] as {
|
|
removed_by: string;
|
|
removed_at: string;
|
|
};
|
|
assert.equal(update.removed_by, member);
|
|
assert.ok(Number.isFinite(Date.parse(update.removed_at)));
|
|
assert.ok(!f.calls.some((c) => c.method === "delete"));
|
|
}
|
|
});
|
|
|
|
test("account reports preserve reasons, block self reports, and translate duplicate errors", async () => {
|
|
const body = {
|
|
target: "account",
|
|
targetId: other,
|
|
category: "advertising",
|
|
reason: "Repeated unsolicited adverts.",
|
|
};
|
|
const f = fixture();
|
|
f.reply("profiles", { id: other });
|
|
f.reply("community_reports", null);
|
|
assert.equal((await f.run("community/reports", "POST", body)).status, 201);
|
|
assert.deepEqual(f.calls.find((c) => c.method === "insert")?.args, [
|
|
{
|
|
reporter_id: member,
|
|
target: "account",
|
|
comment_id: null,
|
|
account_id: other,
|
|
category: "advertising",
|
|
reason: body.reason,
|
|
},
|
|
]);
|
|
await assert.rejects(
|
|
fixture().run("community/reports", "POST", { ...body, targetId: member }),
|
|
/own account/,
|
|
);
|
|
const duplicate = fixture();
|
|
duplicate.reply("profiles", { id: other });
|
|
duplicate.reply("community_reports", null, {
|
|
code: "23505",
|
|
message: "duplicate",
|
|
});
|
|
await assert.rejects(
|
|
duplicate.run("community/reports", "POST", body),
|
|
(e: unknown) => e instanceof ServiceError && e.status === 409,
|
|
);
|
|
});
|
|
|
|
test("comment reports require visible published content; staff reviews use an open-state compare and set", async () => {
|
|
const hidden = fixture();
|
|
await assert.rejects(
|
|
hidden.run("community/reports", "POST", {
|
|
target: "comment",
|
|
targetId: comment,
|
|
category: "spam",
|
|
reason: "Repeated unsolicited adverts.",
|
|
}),
|
|
/not found/,
|
|
);
|
|
assert.ok(!hidden.calls.some((c) => c.method === "insert"));
|
|
const f = fixture({ role: "moderator" });
|
|
f.reply("community_reports", { id: comment });
|
|
assert.equal(
|
|
(
|
|
await f.run(`moderation/community-reports/${comment}`, "PATCH", {
|
|
decision: "dismissed",
|
|
reason: "The comment follows the rules.",
|
|
})
|
|
).status,
|
|
200,
|
|
);
|
|
assert.ok(
|
|
f.calls.some(
|
|
(c) => c.method === "eq" && c.args[0] === "state" && c.args[1] === "open",
|
|
),
|
|
);
|
|
const update = f.calls.find((c) => c.method === "update")?.args[0] as {
|
|
reviewer_id: string;
|
|
state: string;
|
|
};
|
|
assert.equal(update.reviewer_id, member);
|
|
assert.equal(update.state, "dismissed");
|
|
const stale = fixture({ role: "admin" });
|
|
await assert.rejects(
|
|
stale.run(`moderation/community-reports/${comment}`, "PATCH", {
|
|
decision: "resolved",
|
|
reason: "The issue was addressed by staff.",
|
|
}),
|
|
(e: unknown) => e instanceof ServiceError && e.status === 409,
|
|
);
|
|
});
|