Die Tests starten Postgres per PGlite und sprechen es ueber einen lokalen Port an, laufen also ohne Docker und ohne installierte Datenbank. Abgedeckt sind die Guthaben-Regeln, der CC-Lebenszyklus samt Reaktivierung, Stornos und Ruecknahmen, Anmeldung mit CSRF- und Rollenpruefung sowie der Import mit Dedup.
289 lines
8.2 KiB
TypeScript
289 lines
8.2 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('verraet 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('laesst 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 gefaelschten 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('laesst 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('laesst 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 frueh' },
|
|
});
|
|
|
|
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 ueber die API', () => {
|
|
it('ein CC-Paket schlaegt in beiden Toepfen 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');
|
|
});
|
|
});
|