Der Zugang aus der Erstinstallation ist ab Werk unantastbar: weder Rolle noch Zustand lassen sich aendern, sein Passwort nicht von aussen zuruecksetzen, und loeschen laesst er sich nicht - auch nicht durch sich selbst. So bleibt ein Weg zurueck, wenn sich jemand aus Versehen die Rechte entzieht. Sein eigenes Passwort aendert er weiterhin unter "Konto". BOOTSTRAP_ADMIN_EDITABLE=true hebt den Schutz auf. Welcher Zugang gemeint ist, traegt der Start nach: den Namen kennt nur er, die Migration nicht. Ein CC behielt nach dem Verschieben seines Redesigns den Vermerk "Redesignt", obwohl ueber ihm nichts mehr stand. Der Vermerk stammte von einem Nachfolger, der inzwischen woanders lag. Jetzt folgt er der Kette: wer einen Nachfolger hat, wurde ersetzt - wer ans Ende rueckt, nicht mehr. Ein Platz entsteht dabei nicht aus dem Nichts, die Deckung wird geprueft wie sonst auch. Neu bearbeitbar: Name, Beschreibung und der verknuepfte Forums-Antrag eines CCs. Ein Antrag, den der Abgleich noch nicht kennt, wird dabei vorgemerkt - sonst staende er beim naechsten Lauf als offener Antrag da, obwohl er laengst eingetragen ist. Ebenso laesst sich nachtraeglich umstellen, ob ein Redesign bezahlt oder Kulanz war; umgebucht wird ueber eine Gegenbuchung, ueberschrieben wird nichts. Zuletzt: Eintraege eines anderen Editors kamen nicht an, solange die Seite offen stand. Jetzt wird der Stand geholt, sobald der Tab wieder in den Vordergrund kommt - das trifft den Moment, in dem jemand wieder hinsieht, ohne im Hintergrund dauernd zu fragen.
449 lines
14 KiB
TypeScript
449 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('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('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/);
|
|
});
|
|
});
|