Files
CC-Manager/server/test/http.test.ts
T
Harlekin ae5c67eea6 Von Hand vergebenes CC-Guthaben ohne Redesign
Der Knopf trug bisher die Regel des Shops: ein CC-Paket ist ein Platz plus
ein Redesign. Fuer Einzelfaelle - jemand bekommt einen Platz als Ausgleich,
aus einer Absprache, weil etwas schiefging - war das zu viel.

"+ CC-Guthaben" schreibt jetzt nur den Platz gut. Wer ein volles Paket
nachtragen will, klickt zusaetzlich auf "+ Redesign-Paket". Der Abgleich mit
dem Shop bleibt unberuehrt: dort ist ein Paket weiterhin beides, und die
Regel dafuer steht unveraendert an einer Stelle.

In der Historie steht "CC-Guthaben (von Hand)" statt "CC-Paket" - eine eigene
Quelle, damit zu sehen ist, dass kein Kauf dahintersteht. Das Minus daneben
nimmt entsprechend diese Zugabe zurueck.
2026-09-21 23:43:26 +02:00

452 lines
14 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { FastifyInstance } from 'fastify';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let app: FastifyInstance;
let db: Db;
const PASSWORD = 'TestPasswort123';
interface Credentials {
session: string;
csrf: string;
}
async function login(username: string, password = PASSWORD): Promise<Credentials> {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username, password },
});
assert.equal(response.statusCode, 200, `Login von ${username} fehlgeschlagen`);
const session = response.cookies.find((c) => c.name === 'ccm_sid');
const csrf = response.cookies.find((c) => c.name === 'ccm_csrf');
assert.ok(session && csrf, 'Sitzungs-Cookies fehlen');
return { session: session.value, csrf: csrf.value };
}
function authHeaders(credentials: Credentials, withCsrf = true) {
return {
cookie: `ccm_sid=${credentials.session}; ccm_csrf=${credentials.csrf}`,
...(withCsrf ? { 'x-csrf-token': credentials.csrf } : {}),
};
}
before(async () => {
database = await startTestDatabase(54330);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
process.env.COOKIE_SECURE = 'false';
db = await import('../src/db.js');
const { hashPassword } = await import('../src/auth/password.js');
const { buildApp } = await import('../src/app.js');
const passwordHash = await hashPassword(PASSWORD);
await db.prisma.user.createMany({
data: [
{ username: 'chef', passwordHash, role: 'ADMIN' },
{ username: 'team', passwordHash, role: 'EDITOR' },
{ username: 'gast', passwordHash, role: 'VIEWER' },
{ username: 'neu', passwordHash, role: 'EDITOR', mustChangePassword: true },
{ username: 'gesperrt', passwordHash, role: 'EDITOR', isActive: false },
],
});
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
await db.prisma.$disconnect();
await database.stop();
});
describe('Anmeldung', () => {
it('weist ein falsches Passwort ab', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'chef', password: 'falsch' },
});
assert.equal(response.statusCode, 401);
});
it('verrät nicht, ob es den Benutzernamen gibt', async () => {
const unknownUser = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'gibtesnicht', password: 'falsch' },
});
const wrongPassword = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'chef', password: 'falsch' },
});
assert.equal(unknownUser.statusCode, wrongPassword.statusCode);
assert.equal(unknownUser.json().message, wrongPassword.json().message);
});
it('lässt ein gesperrtes Konto nicht herein', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'gesperrt', password: PASSWORD },
});
assert.equal(response.statusCode, 401);
});
it('setzt bei Erfolg beide Cookies', async () => {
const credentials = await login('chef');
assert.ok(credentials.session.length > 20);
assert.ok(credentials.csrf.length > 20);
});
});
describe('CSRF-Schutz', () => {
it('blockt eine schreibende Anfrage ohne Token', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials, false),
payload: { displayName: 'Ohne Token' },
});
assert.equal(response.statusCode, 403);
assert.equal(response.json().error, 'FORBIDDEN');
});
it('blockt einen gefälschten Token', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: { ...authHeaders(credentials, false), 'x-csrf-token': 'a'.repeat(64) },
payload: { displayName: 'Falscher Token' },
});
assert.equal(response.statusCode, 403);
});
it('lässt die Anfrage mit passendem Token durch', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'Mit Token' },
});
assert.equal(response.statusCode, 201);
});
it('lässt lesende Anfragen ohne Token zu', async () => {
const credentials = await login('gast');
const response = await app.inject({
method: 'GET',
url: '/api/players',
headers: authHeaders(credentials, false),
});
assert.equal(response.statusCode, 200);
});
});
describe('Rollen', () => {
it('ein Viewer darf lesen, aber nichts anlegen', async () => {
const credentials = await login('gast');
const read = await app.inject({
method: 'GET',
url: '/api/players',
headers: authHeaders(credentials),
});
const write = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'Verboten' },
});
assert.equal(read.statusCode, 200);
assert.equal(write.statusCode, 403);
});
it('ein Editor kommt nicht in den Admin-Bereich', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'GET',
url: '/api/admin/users',
headers: authHeaders(credentials),
});
assert.equal(response.statusCode, 403);
});
it('ein Admin kommt in den Admin-Bereich', async () => {
const credentials = await login('chef');
const response = await app.inject({
method: 'GET',
url: '/api/admin/users',
headers: authHeaders(credentials),
});
assert.equal(response.statusCode, 200);
assert.ok(Array.isArray(response.json().items));
});
it('ohne Anmeldung ist alles zu', async () => {
const response = await app.inject({ method: 'GET', url: '/api/players' });
assert.equal(response.statusCode, 401);
});
});
describe('Der Zugang aus der Erstinstallation', () => {
/** Legt einen geschützten Admin an, wie es der erste Start tut. */
async function erstzugang() {
return db.prisma.user.create({
data: {
username: 'erstzugang',
passwordHash: 'x',
role: 'ADMIN',
isProtected: true,
},
});
}
it('lässt sich seine Rolle nicht nehmen', async () => {
const geschuetzt = await erstzugang();
const credentials = await login('chef');
const response = await app.inject({
method: 'PATCH',
url: `/api/admin/users/${geschuetzt.id}`,
headers: authHeaders(credentials),
payload: { role: 'VIEWER' },
});
assert.equal(response.statusCode, 409);
assert.equal(response.json().error, 'PROTECTED_ACCOUNT');
const unveraendert = await db.prisma.user.findUniqueOrThrow({ where: { id: geschuetzt.id } });
assert.equal(unveraendert.role, 'ADMIN');
});
it('lässt sich nicht sperren', async () => {
const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } });
const credentials = await login('chef');
const response = await app.inject({
method: 'PATCH',
url: `/api/admin/users/${geschuetzt.id}`,
headers: authHeaders(credentials),
payload: { isActive: false },
});
assert.equal(response.statusCode, 409);
});
it('bekommt sein Passwort nicht von aussen zurückgesetzt', async () => {
const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } });
const credentials = await login('chef');
const response = await app.inject({
method: 'POST',
url: `/api/admin/users/${geschuetzt.id}/reset-password`,
headers: authHeaders(credentials),
payload: { newPassword: 'EinLangesPasswort1' },
});
assert.equal(response.statusCode, 409);
});
it('lässt sich nicht löschen', async () => {
const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } });
const credentials = await login('chef');
const response = await app.inject({
method: 'DELETE',
url: `/api/admin/users/${geschuetzt.id}`,
headers: authHeaders(credentials),
});
assert.equal(response.statusCode, 409);
assert.ok(await db.prisma.user.findUnique({ where: { id: geschuetzt.id } }));
});
it('ist in der Liste als geschützt ausgewiesen', async () => {
const credentials = await login('chef');
const response = await app.inject({
method: 'GET',
url: '/api/admin/users',
headers: authHeaders(credentials),
});
const eintrag = response
.json()
.items.find((u: { username: string }) => u.username === 'erstzugang');
assert.equal(eintrag.isLocked, true, 'die Oberfläche soll die Knöpfe gar nicht erst zeigen');
});
it('lässt gewöhnliche Konten unangetastet', async () => {
const credentials = await login('chef');
const normal = await db.prisma.user.findFirstOrThrow({ where: { username: 'gast' } });
const response = await app.inject({
method: 'PATCH',
url: `/api/admin/users/${normal.id}`,
headers: authHeaders(credentials),
payload: { role: 'EDITOR' },
});
assert.equal(response.statusCode, 200, 'der Schutz gilt nur für den Erstzugang');
await app.inject({
method: 'PATCH',
url: `/api/admin/users/${normal.id}`,
headers: authHeaders(credentials),
payload: { role: 'VIEWER' },
});
});
});
describe('Erstpasswort', () => {
it('sperrt schreibende Aktionen bis zum Wechsel', async () => {
const credentials = await login('neu');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'Zu früh' },
});
assert.equal(response.statusCode, 403);
assert.match(response.json().message, /Passwort/);
});
it('erlaubt genau den Passwortwechsel', async () => {
const credentials = await login('neu');
const response = await app.inject({
method: 'POST',
url: '/api/account/password',
headers: authHeaders(credentials),
payload: { currentPassword: PASSWORD, newPassword: 'NeuesPasswort456' },
});
assert.equal(response.statusCode, 200);
const user = await db.prisma.user.findUniqueOrThrow({ where: { username: 'neu' } });
assert.equal(user.mustChangePassword, false);
});
});
describe('Guthaben über die API', () => {
it('von Hand vergeben schreibt nur das CC-Guthaben gut', async () => {
const credentials = await login('team');
const created = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'API-Spieler' },
});
const playerId = created.json().id;
const booked = await app.inject({
method: 'POST',
url: `/api/players/${playerId}/credits/cc-package`,
headers: authHeaders(credentials),
payload: {},
});
assert.equal(booked.statusCode, 200);
// Ein im Shop gekauftes Paket bringt ein Redesign mit; hier geht es um
// den Einzelfall, in dem jemand nur einen Platz bekommt.
assert.deepEqual(booked.json().balances, {
CC: 1,
REDESIGN_PAID: 0,
REDESIGN_KULANZ: 0,
redesignTotal: 0,
granted: { CC: 1, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 },
});
});
it('eine unbekannte API-Route liefert sauberes JSON', async () => {
const response = await app.inject({ method: 'GET', url: '/api/gibtesnicht' });
assert.equal(response.statusCode, 404);
assert.equal(response.json().error, 'NOT_FOUND');
});
});
describe('Auslieferung der Oberfläche', () => {
// Diese Header sind der Grund, warum ein Update beim Nutzer ankommt. Wird die
// index.html cachebar ausgeliefert, sieht er nach einem Deploy weiter die
// alte Oberfläche - ohne dass irgendwo ein Fehler auftaucht.
it('lässt die index.html nicht zwischenspeichern', async () => {
const response = await app.inject({ method: 'GET', url: '/' });
assert.equal(response.statusCode, 200);
assert.match(
response.headers['cache-control'] as string,
/no-cache/,
'die index.html muss bei jedem Aufruf neu geholt werden',
);
});
it('lässt Antworten der Schnittstelle gar nicht erst in den Cache', async () => {
// Ohne Angabe entscheidet der Browser selbst, wie lange er eine Antwort
// aufhebt - dann steht nach dem Anlegen eines CCs die alte Liste da.
const response = await app.inject({ method: 'GET', url: '/api/auth/me' });
assert.equal(response.headers['cache-control'], 'no-store');
});
it('liefert auch Deep-Links ohne Cache aus', async () => {
const response = await app.inject({ method: 'GET', url: '/players/1' });
assert.equal(response.statusCode, 200);
assert.match(response.headers['cache-control'] as string, /no-cache/);
});
it('lässt Bilder nicht ein Jahr lang kleben', async () => {
// Sie tragen keinen Hash im Namen - ein Austausch muss ankommen.
const response = await app.inject({ method: 'GET', url: '/favicon.png' });
assert.equal(response.statusCode, 200);
const header = response.headers['cache-control'] as string;
assert.doesNotMatch(header, /immutable/, 'kein immutable ohne Hash im Namen');
assert.match(header, /must-revalidate/);
});
it('lässt gehashte Assets lange liegen', async () => {
const page = await app.inject({ method: 'GET', url: '/' });
const asset = /\/assets\/[^"]+\.js/.exec(page.body)?.[0];
assert.ok(asset, 'die Seite verweist auf ein Asset');
const response = await app.inject({ method: 'GET', url: asset });
assert.equal(response.statusCode, 200);
assert.match(response.headers['cache-control'] as string, /max-age=31536000/);
});
});