Capture team chat and expose moderation match archives

This commit is contained in:
2026-10-17 20:11:49 -04:00
parent b678de2f6f
commit 870f13b150
28 changed files with 943 additions and 81 deletions
+239
View File
@@ -0,0 +1,239 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../src/lib/auth.js';
import {
matchApiParams,
matchHistoryHref,
readMatchQuery
} from '../../src/lib/match-history-query.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const server = 't5_zombie_theater_1';
const clock = '2026-10-17T12:00:00.000Z';
const end = '2026-10-17T12:01:00.000Z';
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
handle
.prepare("INSERT INTO servers(name,game,map) VALUES(?,'t5','zombie_theater')")
.run(server);
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
handle
.prepare(
"INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES('t5','123','Player',?,?)"
)
.run(clock, end);
const insert =
handle.prepare(`INSERT INTO leaderboard_matches(id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_observed,highest_round,last_event,last_fingerprint)
VALUES(?,?,'t5','zombie_theater','t5:zombie_theater','Kino Der Toten',?,?,?,?,1,?,'game_end','test')`);
for (const [id, status, round] of [
[1, 'completed', 1],
[2, 'interrupted', 1],
[3, 'active', 0],
[4, 'end-report-missing', 2],
[5, 'completed', 8],
[6, 'completed', 1]
] as const) {
insert.run(id, id === 6 ? 'other-server' : server, clock, end, end, status, round);
handle
.prepare(
`INSERT INTO leaderboard_match_players(match_id,guid,highest_round,has_stats,completed,first_seen_at,last_seen_at)
VALUES(?,'123',?,1,?,?,?)`
)
.run(id, round, status === 'completed' ? 1 : 0, clock, end);
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
id,
`message-${id}`,
1,
JSON.stringify({
type: 'message',
message: {
time: '1:00',
guid: '123',
slot: 1,
player: 'Player',
message: `Message from match ${id}`
},
context: { realTime: null, matchElapsedMs: null, round: null }
})
);
}
});
afterEach(async () => fixture.cleanup());
async function user(role: string): Promise<AuthUser> {
const { getUser } = await import('../../src/lib/server/auth/index.js');
return getUser(role)!;
}
async function api(actor: AuthUser | null, scoped = false, archive = '1') {
const url = new URL(`http://localhost/api/matches?archive=${archive}`);
if (scoped) {
const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
return GET({ url, params: { name: server }, locals: { user: actor } });
}
const { GET } = await import('../../src/routes/api/matches/+server.js');
return GET({ url, locals: { user: actor } });
}
async function page(
actor: AuthUser | null,
surface: 'list' | 'detail' | 'instance',
id = '1',
archive = '1'
) {
const url = new URL(`http://localhost/matches/${id}?matchArchive=${archive}`);
const setHeaders = vi.fn();
let result;
if (surface === 'list') {
const { load } = await import('../../src/routes/matches/+page.server.js');
result = load({ url, locals: { user: actor }, setHeaders });
} else if (surface === 'detail') {
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
result = load({ url, params: { id }, locals: { user: actor }, setHeaders });
} else {
const { load } = await import('../../src/routes/servers/[name]/+page.server.js');
result = load({ url, params: { name: server }, locals: { user: actor }, setHeaders });
}
return { result, setHeaders };
}
it('makes archive selection explicit, strictly parses it and preserves it through namespaced navigation', () => {
expect(readMatchQuery(new URLSearchParams()).archive).toBe(false);
expect(readMatchQuery(new URLSearchParams('archive=0')).archive).toBe(false);
expect(readMatchQuery(new URLSearchParams('archive=1')).archive).toBe(true);
for (const value of ['true', 'yes', '2'])
expect(() => readMatchQuery(new URLSearchParams(`archive=${value}`))).toThrow(
'Invalid match history filters'
);
const url = new URL('http://localhost/matches?matchPage=3&matchStatus=interrupted');
const href = matchHistoryHref(url, { archive: '1' });
expect(href).toContain('matchArchive=1');
expect(href).not.toContain('matchPage');
expect(matchApiParams(new URL(href, url)).get('archive')).toBe('1');
});
it('exposes saved round-one, interrupted, active and missing-end chat only in expanded queries without changing gameplay qualification', async () => {
const { getMatchDetail, serverMatchHistory } =
await import('../../src/lib/server/fleet/match-history.js');
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const publicBefore = getLeaderboard();
expect(publicBefore.entries[0].gamesPlayed).toBe(1);
expect(serverMatchHistory().total).toBe(2);
const archive = readMatchQuery(new URLSearchParams('archive=1'));
expect(serverMatchHistory(undefined, 25, archive).total).toBe(6);
expect(serverMatchHistory(server, 25, archive).total).toBe(5);
expect(
serverMatchHistory(
server,
25,
readMatchQuery(new URLSearchParams('archive=1&status=interrupted'))
).matches.map((item) => item.id)
).toEqual([2]);
for (const id of [1, 2, 3, 4, 6]) {
expect(getMatchDetail(id)).toBeNull();
expect(getMatchDetail(id, true)?.chat).toMatchObject([
{ message: { message: `Message from match ${id}` }, messageId: `${id}:message-${id}:1` }
]);
}
for (const id of [2, 3, 4])
expect(getMatchDetail(id, true)).toMatchObject({
finalRound: null,
durationMs: null,
endedAt: null,
endPlayers: null,
players: [{ presentAtEnd: false }]
});
expect(getMatchDetail(5)).toMatchObject({ status: 'completed', finalRound: 8 });
expect(getLeaderboard()).toEqual(publicBefore);
handle.prepare('DELETE FROM servers WHERE name=?').run(server);
expect(getMatchDetail(2, true)?.chat).toHaveLength(1);
});
it.each(['admin', 'moderator', 'super_viewer'])(
'allows %s archive access with private responses on every API and page surface',
async (role) => {
const actor = await user(role);
for (const scoped of [false, true]) {
const response = await api(actor, scoped);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect((await response.json()).total).toBe(scoped ? 5 : 6);
}
for (const surface of ['list', 'detail', 'instance'] as const) {
const loaded = await page(actor, surface);
expect(loaded.setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
if (surface === 'detail')
expect(loaded.result).toMatchObject({
match: { id: 1, status: 'completed', finalRound: 1 }
});
}
}
);
it.each(['operator', 'viewer', null])(
'rejects %s expanded access before revealing match existence',
async (role) => {
const actor = role ? await user(role) : null;
for (const scoped of [false, true]) {
const response = await api(actor, scoped);
expect(response.status).toBe(403);
expect(response.headers.get('Cache-Control')).toBe('no-store');
}
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
await expect(page(actor, 'detail', '999')).rejects.toMatchObject({ status: 403 });
expect((await api(actor, false, '0')).status).toBe(200);
await expect(page(actor, 'detail', '1', '0')).rejects.toMatchObject({ status: 404 });
expect((await page(actor, 'detail', '5', '0')).result).toMatchObject({ match: { id: 5 } });
}
);
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
'revalidates stale moderator locals after %s',
async (change) => {
const actor = await user('moderator');
handle.prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
expect((await api(actor)).status).toBe(403);
expect((await api(actor, true)).status).toBe(403);
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
}
);
it('returns validation and not-found errors without silently falling back to the standard history', async () => {
const actor = await user('moderator');
expect((await api(actor, false, 'true')).status).toBe(400);
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface, '1', 'true')).rejects.toMatchObject({ status: 400 });
for (const id of ['0', '01', '-1', '1.5', '999'])
await expect(page(actor, 'detail', id)).rejects.toMatchObject({ status: 404 });
});
it('attaches interrupted-match archive evidence and keeps the snapshot after source cleanup', async () => {
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
const entry = getMatchDetail(2, true)!.chat[0];
const { id } = mutateModeration('moderator', {
op: 'create-case',
game: 't5',
guid: '123',
title: 'Interrupted match report',
evidence: { matchId: 2, messageIds: [entry.messageId] },
idempotencyKey: randomUUID()
});
handle.prepare('DELETE FROM leaderboard_matches WHERE id=2').run();
const evidence = moderationCaseDetail('moderator', id).case.evidence[0];
expect(JSON.parse(evidence.snapshot!).entry.message.message).toBe('Message from match 2');
});