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>
111 lines
4.9 KiB
TypeScript
111 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|