Datenminimierung: Rohdaten, Protokoll und Loeschung

Drei Befunde aus einer Durchsicht der gespeicherten personenbezogenen Daten:

Der Import legte bisher jede Spalte der hochgeladenen Datei dauerhaft ab,
auch Mailadressen oder Rechnungsdaten, die nie zugeordnet wurden. Nach dem
Buchen oder Verwerfen bleiben jetzt nur noch die gemappten Spalten stehen.

Ein fehlgeschlagener Login protokollierte den eingetippten Benutzernamen.
Wer sein Passwort ins Namensfeld tippt, hatte es damit im Klartext im
Audit-Log. Der Name wird nur noch gespeichert, wenn das Konto existiert.

Das Loeschen eines Spielers liess Spuren in den Import-Zeilen und in den
Details frueherer Audit-Eintraege zurueck. Beides wird jetzt mit abgeraeumt;
die Vorgaenge bleiben als Eintrag bestehen, nur ohne Inhalt.

Dazu eine Aufbewahrungsfrist fuer das Audit-Log (Standard 12 Monate, ueber
AUDIT_RETENTION_DAYS einstellbar). Die Guthaben-Historie ist davon nicht
betroffen - sie bleibt die Quelle der Wahrheit fuer bezahlte Ansprueche.
This commit is contained in:
2026-09-18 15:22:02 +02:00
parent f775b0a594
commit bc86e2df26
8 changed files with 363 additions and 12 deletions
+183
View File
@@ -0,0 +1,183 @@
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 koennte: 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: 'datenschuetzer', 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('raeumt 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('Loeschung 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 Vorgaenge 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('loescht Eintraege 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 } }));
});
});