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.
184 lines
7.0 KiB
TypeScript
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 } }));
|
|
});
|
|
});
|