Files
CC-Manager/server/test/http.test.ts
T
Harlekin 3344592c6e Maskottchen als Symbol und Credits
harle.png steht jetzt gross auf der Anmeldeseite und klein in der Fusszeile,
dazu die Zeile "Created by Harlekin | CC-Head".

Aus der Vorlage entstehen drei Groessen: das volle Bild fuer die Anzeige und
ein Ausschnitt des Kopfes fuer Favicon und Startsymbol - ganzfigurig waere die
Figur bei 32 Pixeln nicht mehr zu erkennen.

Dabei fiel auf, dass die Cache-Regel zu grob war: sie gab allem ausser HTML
ein Jahr immutable, also auch Bildern. Deren Name aendert sich beim Austausch
aber nicht, ein neues Bild waere ein Jahr lang nicht angekommen. Jetzt gilt
das lange Ablaufdatum nur fuer die gebauten Assets, die einen Inhalts-Hash im
Namen tragen; alles andere haelt einen Tag und fragt dann nach.
2026-09-18 19:44:29 +02:00

332 lines
9.9 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('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('ein CC-Paket schlägt in beiden Töpfen auf', 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);
assert.deepEqual(booked.json().balances, {
CC: 1,
REDESIGN_PAID: 1,
REDESIGN_KULANZ: 0,
redesignTotal: 1,
});
});
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('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/);
});
});