feat: ATM10-Statistikportal

Statistikportal für den All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1):

- mod/: serverseitige NeoForge-Mod atmstats – Ereignisse, Live-Zähler, Servergesundheit,
  FTB-Dateien; Outbox auf der Festplatte, signierter gzip-Versand per HTTPS mit Backoff
- backend/: Fastify + PostgreSQL – idempotenter Ingest, Projektionen, Parser für SNBT,
  FTB Quests/Teams, world/stats, world/advancements und latest.log; Ranglisten, Snapshots,
  Retention, öffentliche API, SSE-Live-Updates, Admin mit argon2/CSRF, SFTP-Worker
- web/: Next.js-Dashboard – Übersicht, Ranglisten, Spielerprofile, ATM10, Server,
  Datenquellen, Verwaltung
- shared/: Protokoll v1 und API-Typen; tools/: Simulator, Log-Anonymisierer, RCON-Client
- deploy/: Docker Compose hinter Traefik, Backup; docs/: Architektur, Datenquellen, Betrieb

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lucashahmannandClaude Opus 5.5 committed 2026-10-13 09:33:25 +02:00
1 parent 56d2078e6a
commit e66f89f49e
214 files changed
+27077

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { createAdmin } from '../src/auth/admin.ts';
import { processEvents } from '../src/pipeline/process.ts';
import { makeApp } from './helpers/app.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, ev } from './helpers/events.ts';
import { signedRequest } from './helpers/sign.ts';
let pool: Pool;
let app: FastifyInstance;
let token: string;
beforeEach(async () => {
await app?.close();
pool = await getTestPool();
await resetDb(pool);
({ app, token } = await makeApp(pool));
await createAdmin(pool, 'lucas', 'correct horse battery staple');
await processEvents(pool, [ev('player_join', { ...ALICE })], 'push', 'atm10');
});
afterAll(async () => {
await app?.close();
await closeTestPool();
});
async function login(password = 'correct horse battery staple') {
const res = await app.inject({ method: 'POST', url: '/api/admin/login', payload: { username: 'lucas', password } });
const cookie = res.cookies.find((c) => c.name === 'atm_admin');
return { res, cookie: cookie ? `atm_admin=${cookie.value}` : '', csrf: res.statusCode === 200 ? res.json().csrf : '' };
}
describe('admin', () => {
it('login with wrong password 401', async () => {
const { res } = await login('wrong');
expect(res.statusCode).toBe(401);
});
it('login sets an httpOnly strict cookie and the session endpoint works', async () => {
const { res, cookie, csrf } = await login();
expect(res.statusCode).toBe(200);
const raw = res.cookies.find((c) => c.name === 'atm_admin')!;
expect(raw.httpOnly).toBe(true);
expect(raw.sameSite).toBe('Strict');
const s = await app.inject({ method: 'GET', url: '/api/admin/session', headers: { cookie } });
expect(s.json()).toEqual({ username: 'lucas', csrf });
});
it('admin routes require a session', async () => {
expect((await app.inject({ method: 'GET', url: '/api/admin/status' })).statusCode).toBe(401);
});
it('mutating route without csrf 403', async () => {
const { cookie } = await login();
const res = await app.inject({
method: 'POST',
url: `/api/admin/players/${ALICE.uuid}/hidden`,
headers: { cookie },
payload: { hidden: true },
});
expect(res.statusCode).toBe(403);
});
it('hide player then profile 404, unhide restores it', async () => {
const { cookie, csrf } = await login();
const hide = (hidden: boolean) =>
app.inject({
method: 'POST',
url: `/api/admin/players/${ALICE.uuid}/hidden`,
headers: { cookie, 'x-csrf-token': csrf },
payload: { hidden },
});
expect((await hide(true)).statusCode).toBe(200);
expect((await app.inject({ method: 'GET', url: `/api/players/${ALICE.uuid}` })).statusCode).toBe(404);
const list = await app.inject({ method: 'GET', url: '/api/admin/players', headers: { cookie } });
expect(list.json().players[0]).toMatchObject({ uuid: ALICE.uuid, hidden: true });
await hide(false);
expect((await app.inject({ method: 'GET', url: `/api/players/${ALICE.uuid}` })).statusCode).toBe(200);
});
it('rotate token invalidates old token for ingest', async () => {
const { cookie, csrf } = await login();
const rot = await app.inject({ method: 'POST', url: '/api/admin/ingest-token/rotate', headers: { cookie, 'x-csrf-token': csrf } });
expect(rot.statusCode).toBe(200);
const fresh = rot.json().token as string;
const send = (t: string) => {
const { headers, payload } = signedRequest({ protocol: 1, events: [ev('server_stop', {})] }, { token: t });
return app.inject({ method: 'POST', url: '/api/ingest', headers, payload });
};
expect((await send(token)).statusCode).toBe(401);
expect((await send(fresh)).statusCode).toBe(200);
});
it('status lists errors and a sync request is queued', async () => {
const { cookie, csrf } = await login();
await pool.query(`INSERT INTO app_errors(source, message) VALUES ('sftp', 'connection refused')`);
const status = await app.inject({ method: 'GET', url: '/api/admin/status', headers: { cookie } });
expect(status.json().lastErrors[0]).toMatchObject({ source: 'sftp', message: 'connection refused' });
const sync = await app.inject({ method: 'POST', url: '/api/admin/sync', headers: { cookie, 'x-csrf-token': csrf } });
expect(sync.statusCode).toBe(202);
expect((await pool.query('SELECT count(*)::int AS n FROM sync_requests WHERE done_at IS NULL')).rows[0].n).toBe(1);
});
it('logout ends the session', async () => {
const { cookie, csrf } = await login();
await app.inject({ method: 'POST', url: '/api/admin/logout', headers: { cookie, 'x-csrf-token': csrf } });
expect((await app.inject({ method: 'GET', url: '/api/admin/status', headers: { cookie } })).statusCode).toBe(401);
});
});