Add community content reports, map bans, and appeals
This commit is contained in:
68
tests/moderation.test.ts
Normal file
68
tests/moderation.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { contributionCutoff, reportSchema } from "../shared/moderation";
|
||||
|
||||
const datasetId = "11111111-1111-4111-8111-111111111111";
|
||||
const sessionId = "22222222-2222-4222-8222-222222222222";
|
||||
const report = {
|
||||
datasetId,
|
||||
target: "map",
|
||||
category: "broken",
|
||||
reason: "Terrain is missing from the map.",
|
||||
};
|
||||
test("reports require an unambiguous target and a meaningful explanation", () => {
|
||||
assert.equal(reportSchema.parse(report).target, "map");
|
||||
for (const change of [
|
||||
{ datasetId: undefined },
|
||||
{ sessionId },
|
||||
{ target: "panorama" },
|
||||
{ reason: " " },
|
||||
{ category: "unknown" },
|
||||
])
|
||||
assert.equal(
|
||||
reportSchema.safeParse({ ...report, ...change }).success,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reportSchema.parse({ ...report, reason: " Terrain is missing. " }).reason,
|
||||
"Terrain is missing.",
|
||||
);
|
||||
});
|
||||
test("panorama reports identify the played session and round without client-provided location IDs", () => {
|
||||
const panorama = {
|
||||
...report,
|
||||
datasetId: undefined,
|
||||
sessionId,
|
||||
target: "panorama",
|
||||
round: 3,
|
||||
};
|
||||
assert.equal(reportSchema.safeParse(panorama).success, true);
|
||||
for (const round of [undefined, -1, 5, 0.5])
|
||||
assert.equal(reportSchema.safeParse({ ...panorama, round }).success, false);
|
||||
});
|
||||
test("evidence links allow only HTTP and HTTPS", () => {
|
||||
for (const evidenceUrl of [
|
||||
"https://example.com/proof",
|
||||
"http://example.com/proof",
|
||||
])
|
||||
assert.equal(
|
||||
reportSchema.safeParse({ ...report, evidenceUrl }).success,
|
||||
true,
|
||||
);
|
||||
for (const evidenceUrl of [
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,test",
|
||||
"file:///secret",
|
||||
"ftp://example.com",
|
||||
])
|
||||
assert.equal(
|
||||
reportSchema.safeParse({ ...report, evidenceUrl }).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("contribution list cutoff is exactly five days and crosses month boundaries", () => {
|
||||
assert.equal(
|
||||
contributionCutoff(Date.parse("2026-10-03T12:34:56Z")),
|
||||
"2026-09-28T12:34:56.000Z",
|
||||
);
|
||||
});
|
||||
90
tests/sql/moderation.sql
Normal file
90
tests/sql/moderation.sql
Normal file
@@ -0,0 +1,90 @@
|
||||
-- Self-contained fixtures; safe to run on the configured project (rolls back).
|
||||
begin;
|
||||
do $$
|
||||
declare owner uuid; moderator uuid; reporter uuid; second uuid; third uuid; fourth uuid; fixture uuid; revision uuid; report uuid; appeal uuid; r2 uuid; r3 uuid; r4 uuid;
|
||||
begin
|
||||
-- Reuse accounts on the configured project; seed accounts in the isolated runtime.
|
||||
if not exists(select 1 from auth.users) then
|
||||
insert into auth.users(id) select gen_random_uuid() from generate_series(1,6);
|
||||
end if;
|
||||
select id into owner from auth.users order by id limit 1;
|
||||
select id into moderator from auth.users where id<>owner order by id limit 1;
|
||||
select id into reporter from auth.users where id not in (owner,moderator) order by id limit 1;
|
||||
select id into second from auth.users where id not in (owner,moderator,reporter) order by id limit 1;
|
||||
select id into third from auth.users where id not in (owner,moderator,reporter,second) order by id limit 1;
|
||||
select id into fourth from auth.users where id not in (owner,moderator,reporter,second,third) order by id limit 1;
|
||||
if fourth is null then raise exception 'Six fixture accounts are required'; end if;
|
||||
insert into public.profiles(id) values(owner),(moderator),(reporter),(second),(third),(fourth) on conflict(id) do nothing;
|
||||
update public.profiles set role='moderator' where id=moderator;
|
||||
update public.profiles set role='contributor' where id in (owner,reporter,second,third,fourth);
|
||||
insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
||||
values(owner,'Report regression','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id into fixture;
|
||||
insert into public.revisions(dataset_id,asset_root,manifest) values(fixture,fixture::text,'{"viewpoints":[{"id":"p0"}]}') returning id into revision;
|
||||
update public.datasets set revision_id=revision where id=fixture;
|
||||
report:=public.submit_content_report(fixture,reporter,revision,'p0','broken','The panorama has missing terrain.');
|
||||
if (select report_strikes from public.datasets where id=fixture)<>0 then raise exception 'Unreviewed report added a strike'; end if;
|
||||
begin
|
||||
perform public.submit_content_report(fixture,reporter,revision,null,'spam','A second report from the same person.');
|
||||
raise exception 'Duplicate report succeeded';
|
||||
exception when others then if sqlerrm not like 'You already reported%' then raise; end if; end;
|
||||
begin
|
||||
perform public.submit_content_report(fixture,owner,revision,null,'spam','An owner reported their own map.');
|
||||
raise exception 'Self-report succeeded';
|
||||
exception when others then if sqlerrm<>'You cannot report your own map' then raise; end if; end;
|
||||
begin
|
||||
perform public.submit_content_report(fixture,second,revision,'unknown','broken','A panorama outside this revision.');
|
||||
raise exception 'Invalid panorama accepted';
|
||||
exception when others then if sqlerrm<>'Reported panorama not found' then raise; end if; end;
|
||||
begin
|
||||
perform public.review_content_report(report,owner,'confirm','Confirmed by the map owner.');
|
||||
raise exception 'Unauthorized moderator succeeded';
|
||||
exception when others then if sqlerrm<>'Moderator permission required' then raise; end if; end;
|
||||
perform public.review_content_report(report,moderator,'dismiss','The reported terrain looks correct.');
|
||||
if (select report_strikes from public.datasets where id=fixture)<>0 then raise exception 'Dismissed report added a strike'; end if;
|
||||
report:=public.submit_content_report(fixture,reporter,revision,null,'spam','The map contains inappropriate advertisements.');
|
||||
perform public.review_content_report(report,moderator,'confirm','Confirmed advertising throughout the map.');
|
||||
begin
|
||||
perform public.review_content_report(report,moderator,'confirm','Replayed moderation request.');
|
||||
raise exception 'Repeated review added a strike';
|
||||
exception when others then if sqlerrm<>'Report is no longer awaiting review' then raise; end if; end;
|
||||
r2:=public.submit_content_report(fixture,second,revision,null,'privacy','The map displays personal information.');
|
||||
r3:=public.submit_content_report(fixture,third,revision,'p0','inappropriate','Offensive content in this panorama.');
|
||||
r4:=public.submit_content_report(fixture,fourth,revision,null,'spam','Another pending community report.');
|
||||
perform public.review_content_report(r2,moderator,'confirm','Confirmed private information in the map.');
|
||||
if not exists(select 1 from public.datasets where id=fixture and report_strikes=2 and state='published' and banned_at is null) then raise exception 'Map unpublished too early'; end if;
|
||||
perform public.review_content_report(r3,moderator,'confirm','Confirmed offensive content in this panorama.');
|
||||
if not exists(select 1 from public.datasets where id=fixture and report_strikes=3 and state='unpublished' and banned_at is not null) then raise exception 'Third strike did not ban map'; end if;
|
||||
begin
|
||||
perform public.create_game(fixture,'normal',null,'test-guest');
|
||||
raise exception 'Banned map started a new game';
|
||||
exception when others then if sqlerrm<>'Map unavailable' then raise; end if; end;
|
||||
begin
|
||||
perform public.submit_draft(fixture,owner);
|
||||
raise exception 'Owner resubmitted a banned map';
|
||||
exception when others then if sqlerrm<>'Draft is not ready for submission' then raise; end if; end;
|
||||
begin
|
||||
update public.datasets set state='published' where id=fixture;
|
||||
raise exception 'Ban bypass succeeded';
|
||||
exception when check_violation then null; end;
|
||||
begin
|
||||
perform public.appeal_map_ban(fixture,reporter,'Please restore someone else''s map.');
|
||||
raise exception 'Non-owner appeal succeeded';
|
||||
exception when others then if sqlerrm<>'Map owner permission required' then raise; end if; end;
|
||||
appeal:=public.appeal_map_ban(fixture,owner,'Please reconsider these reports and restore my map.');
|
||||
begin
|
||||
perform public.appeal_map_ban(fixture,owner,'Another appeal while the first is pending.');
|
||||
raise exception 'Duplicate appeal succeeded';
|
||||
exception when others then if sqlerrm<>'An appeal is already awaiting review' then raise; end if; end;
|
||||
perform public.review_map_appeal(appeal,moderator,'reject','The violations are present and the ban remains.');
|
||||
if (select banned_at from public.datasets where id=fixture) is null then raise exception 'Rejected appeal lifted ban'; end if;
|
||||
appeal:=public.appeal_map_ban(fixture,owner,'Here is new context explaining why the reports were mistaken.');
|
||||
perform public.review_map_appeal(appeal,moderator,'approve','Evidence confirms the map should be restored.');
|
||||
if not exists(select 1 from public.datasets where id=fixture and report_strikes=0 and state='published' and banned_at is null and report_cycle=1 and revision_id=revision) then raise exception 'Appeal did not restore approved version'; end if;
|
||||
if (select state from public.reports where id=r4)<>'dismissed' then raise exception 'Stale reports survived appeal'; end if;
|
||||
if (select count(*) from public.reports where dataset_id=fixture and state='confirmed')<>3 then raise exception 'Report history was lost'; end if;
|
||||
perform public.submit_content_report(fixture,reporter,revision,null,'broken','A new issue after the approved appeal.');
|
||||
if has_function_privilege('authenticated','public.review_content_report(uuid,uuid,text,text)','EXECUTE') or has_function_privilege('anon','public.appeal_map_ban(uuid,uuid,text)','EXECUTE') then raise exception 'Privileged function exposed'; end if;
|
||||
if not (select relrowsecurity from pg_class where oid='public.map_appeals'::regclass) then raise exception 'Appeal RLS is disabled'; end if;
|
||||
if has_table_privilege('authenticated','public.map_appeals','SELECT') or has_table_privilege('anon','public.map_appeals','INSERT') then raise exception 'Appeal history exposed to public API'; end if;
|
||||
end; $$;
|
||||
rollback;
|
||||
Reference in New Issue
Block a user