Files
Harlekin 724f68644b Umlaute statt Umschreibungen
Oberflaeche, Fehlermeldungen und Kommentare schrieben durchgehend ae, oe, ue
und ss. Dafuer gab es keinen Grund - die Anwendung ist deutschsprachig und
laeuft durchgehend in UTF-8.

Betroffen waren 55 Dateien, darunter alle sichtbaren Texte: Guthaben-Zaehler,
Import, Nutzerverwaltung, Passwortwechsel und die Zwei-Faktor-Einrichtung.

Der Name einer Migration bleibt im README-Beispiel bewusst bei ASCII, weil
daraus ein Verzeichnisname wird.
2026-09-18 16:57:39 +02:00

184 lines
7.0 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Imports = typeof import('../src/services/imports.js');
type Players = typeof import('../src/services/players.js');
type Characters = typeof import('../src/services/characters.js');
type Credits = typeof import('../src/services/credits.js');
type Audit = typeof import('../src/services/audit.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let imports: Imports;
let players: Players;
let characters: Characters;
let credits: Credits;
let audit: Audit;
let db: Db;
let ctx: { actorId: number; actorName: string };
// Ein Export, wie ihn ein Shop liefern könnte: mehr Spalten als die App braucht.
const CSV = [
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
].join('\n');
const MAPPING = {
playerKey: 'Steam-ID',
product: 'Produkt',
orderId: 'Bestellnummer',
createMissingPlayers: true,
};
before(async () => {
database = await startTestDatabase(54333);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
db = await import('../src/db.js');
imports = await import('../src/services/imports.js');
players = await import('../src/services/players.js');
characters = await import('../src/services/characters.js');
credits = await import('../src/services/credits.js');
audit = await import('../src/services/audit.js');
const user = await db.prisma.user.create({
data: { username: 'datenschützer', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
await db.prisma.productMapping.create({
data: { matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 },
});
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
describe('Datenminimierung beim Import', () => {
it('verwirft nach dem Buchen die nicht zugeordneten Spalten', async () => {
const parsed = imports.parseUpload('shop.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('shop.csv', parsed, ctx);
await imports.applyMapping(importId, MAPPING, ctx);
const before = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
const rawBefore = before.raw as Record<string, string>;
assert.equal(rawBefore.Email, 'spieler@example.com', 'vor dem Buchen ist noch alles da');
await imports.commitImport(importId, ctx);
const after = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
const rawAfter = after.raw as Record<string, string>;
assert.equal(rawAfter.Email, undefined, 'die Mailadresse ist weg');
assert.equal(rawAfter.Rechnungsadresse, undefined, 'die Anschrift ist weg');
assert.equal(rawAfter.Zahlungsart, undefined, 'die Zahlungsart ist weg');
assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt');
});
it('räumt auch einen verworfenen Import auf', async () => {
const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('shop2.csv', parsed, ctx);
await imports.applyMapping(importId, MAPPING, ctx);
await imports.discardImport(importId, ctx);
const row = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
const raw = row.raw as Record<string, string>;
assert.equal(raw.Email, undefined, 'auch ohne Buchung bleiben keine Rohdaten liegen');
});
});
describe('Fehlgeschlagene Anmeldung', () => {
it('schreibt einen unbekannten Benutzernamen nicht ins Protokoll', async () => {
// Genau der Fall: jemand tippt sein Passwort ins Namensfeld.
const versehen = 'MeinGeheimesPasswort123';
await audit.recordAudit(db.prisma, { actorId: null, actorName: null, ip: '127.0.0.1' }, {
action: 'auth.login_failed',
targetType: 'user',
targetId: null,
details: { reason: 'unknown_account' },
});
const entries = await db.prisma.auditLog.findMany({
where: { action: 'auth.login_failed' },
});
for (const entry of entries) {
assert.notEqual(entry.actorName, versehen);
assert.equal(entry.actorName, null, 'ohne bekanntes Konto bleibt das Namensfeld leer');
}
});
});
describe('Löschung eines Spielers', () => {
it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => {
// Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik.
const csv = [
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
'P-3;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
].join('\n');
const parsed = imports.parseUpload('shop3.csv', Buffer.from(csv, 'utf8'));
const importId = await imports.createImport('shop3.csv', parsed, ctx);
await imports.applyMapping(importId, MAPPING, ctx);
await imports.commitImport(importId, ctx);
const player = await db.prisma.player.findUniqueOrThrow({
where: { steamId: '76561198000000501' },
});
await characters.createCharacter(player.id, { name: 'Testfigur' }, ctx);
await players.deletePlayer(player.id, ctx);
const gone = await db.prisma.player.findUnique({ where: { id: player.id } });
assert.equal(gone, null, 'der Spieler ist weg');
const ledgerLeft = await db.prisma.creditLedger.count({ where: { playerId: player.id } });
assert.equal(ledgerLeft, 0, 'die Guthaben-Zeilen sind mit weg');
const rows = await db.prisma.importRow.findMany({ where: { importId } });
for (const row of rows) {
assert.equal(row.playerKey, null, 'die SteamID steht nicht mehr in der Import-Zeile');
assert.equal(row.resolvedPlayerId, null);
assert.deepEqual(row.raw, {}, 'die Rohzeile ist geleert');
}
const auditEntries = await db.prisma.auditLog.findMany({
where: { targetType: 'player', targetId: String(player.id) },
});
assert.ok(auditEntries.length > 0, 'die Vorgänge selbst bleiben nachvollziehbar');
for (const entry of auditEntries) {
const details = JSON.stringify(entry.details ?? {});
assert.ok(
!details.includes('76561198000000501'),
`SteamID steckt noch in ${entry.action}: ${details}`,
);
}
});
});
describe('Aufbewahrung des Audit-Logs', () => {
it('löscht Einträge jenseits der Frist, neuere bleiben', async () => {
const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000);
const veraltet = await db.prisma.auditLog.create({
data: { action: 'test.alt', targetType: 'test', createdAt: alt },
});
const frisch = await db.prisma.auditLog.create({
data: { action: 'test.frisch', targetType: 'test' },
});
const removed = await audit.cleanupOldAuditEntries();
assert.ok(removed >= 1);
assert.equal(await db.prisma.auditLog.findUnique({ where: { id: veraltet.id } }), null);
assert.ok(await db.prisma.auditLog.findUnique({ where: { id: frisch.id } }));
});
});