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:
1 parent
56d2078e6a
commit
e66f89f49e
214 files changed
+27077
No files matched your search
@@ -0,0 +1,128 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user