Files
lucashahmannandClaude Opus 5.5 e66f89f49e 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>
2026-10-13 09:33:25 +02:00

129 lines
4.5 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { makeApp } from './helpers/app.ts';
import { ALICE, ev } from './helpers/events.ts';
import { signature, signedRequest, TEST_SECRET } 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));
});
afterAll(async () => {
await app?.close();
await closeTestPool();
});
function post(body: unknown, opts: Partial<Parameters<typeof signedRequest>[1]> = {}) {
const { headers, payload } = signedRequest(body, { token, ...opts });
return app.inject({ method: 'POST', url: '/api/ingest', headers, payload });
}
const batch = () => ({
protocol: 1,
events: [ev('player_join', { ...ALICE }), ev('player_leave', { ...ALICE })],
});
describe('POST /api/ingest', () => {
it('accepts valid batch', async () => {
const b = batch();
const res = await post(b);
expect(res.statusCode).toBe(200);
expect(res.json().accepted.sort()).toEqual(b.events.map((e) => e.id).sort());
const { rows } = await pool.query(`SELECT source FROM events`);
expect(rows).toHaveLength(2);
expect(rows.every((r) => r.source === 'push')).toBe(true);
});
it('same batch twice is idempotent', async () => {
const b = batch();
await post(b);
const res = await post(b);
expect(res.statusCode).toBe(200);
expect(res.json().accepted).toEqual([]);
expect(res.json().duplicate.sort()).toEqual(b.events.map((e) => e.id).sort());
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM events`);
expect(rows[0].n).toBe(2);
});
it('rejects wrong token', async () => {
const res = await post(batch(), { token: 'nope' });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: 'bad_token' });
});
it('rejects missing auth', async () => {
const res = await app.inject({ method: 'POST', url: '/api/ingest', payload: batch() });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: 'missing_auth' });
});
it('rejects bad signature', async () => {
const res = await post(batch(), { secret: 'another-secret-another-secret-123' });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: 'bad_signature' });
});
it('rejects timestamp 6 minutes old', async () => {
const res = await post(batch(), { ts: Date.now() - 6 * 60_000 });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: 'timestamp_out_of_range' });
});
it('rejects invalid event but accepts the rest', async () => {
const good = ev('player_join', { ...ALICE });
const bad = { id: '0199d6a0-0000-7000-8000-0000000000ff', type: 'player_join', ts: Date.now(), name: 'x' };
const res = await post({ protocol: 1, events: [good, bad] });
expect(res.statusCode).toBe(200);
expect(res.json().accepted).toEqual([good.id]);
expect(res.json().rejected).toHaveLength(1);
expect(res.json().rejected[0].id).toBe(bad.id);
expect(res.json().rejected[0].reason).toMatch(/uuid/);
});
it('rejects body over 2 MB unpacked', async () => {
const huge = JSON.stringify({ protocol: 1, events: [ev('server_stop', {})], pad: 'x'.repeat(2_200_000) });
const res = await post(huge);
expect(res.statusCode).toBe(413);
});
it('accepts uncompressed body too', async () => {
const res = await post(batch(), { gzip: false });
expect(res.statusCode).toBe(200);
});
});
describe('GET /api/ingest/ping', () => {
it('answers with server time for a valid signature', async () => {
const ts = Date.now();
const res = await app.inject({
method: 'GET',
url: '/api/ingest/ping',
headers: {
authorization: `Bearer ${token}`,
'x-atm-timestamp': String(ts),
'x-atm-signature': signature(TEST_SECRET, ts, Buffer.alloc(0)),
},
});
expect(res.statusCode).toBe(200);
expect(res.json().ok).toBe(true);
expect(typeof res.json().serverTime).toBe('number');
});
});
describe('signature vector (shared with the mod)', () => {
it('matches the constant used in SignerTest.java', () => {
expect(signature('test-secret', 1760000000000, Buffer.from('hello'))).toBe(
'c9f85674a0d206a6d8e53290fb21af4933f8c8451fb8b2aeefcdf3d2314ef043',
);
});
});