diff --git a/.env.example b/.env.example index 3182c99..6bf96ca 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,8 @@ GITEA_API_TOKEN= PREVIEW_DEPLOY_ROOT=/var/www/previews PREVIEW_BASE_HOST=project.webklar.com ALLOWED_CUSTOMER_STATUSES=active + +# Finanzen (Bankabruf Finom über Enable Banking): beides setzen, sonst ist der Bankabruf aus. +# APP_ID = Anwendungs-ID bei Enable Banking, KEY_PATH = Pfad zum privaten Schlüssel (PEM) auf dem Server, nie im Repo. +# ENABLE_BANKING_APP_ID= +# ENABLE_BANKING_KEY_PATH= diff --git a/scripts/setup-finanzen.mjs b/scripts/setup-finanzen.mjs index 8a6bf25..55de8d7 100644 --- a/scripts/setup-finanzen.mjs +++ b/scripts/setup-finanzen.mjs @@ -7,13 +7,15 @@ * Optional uebernimmt das Skript eine bereits bestehende Enable-Banking-Sitzung (einmalig beim Livegang): * --session Sitzungs-ID (wird nie ausgegeben) * --account Konto-UID aus der Sitzung - * --valid-until Ende der Freigabe (ISO-Zeitpunkt) + * --valid-until Ende der Freigabe: Datum (JJJJ-MM-TT) oder voller ISO-Zeitpunkt mit Zeitzone + * (z. B. 2027-03-29T00:00:00Z); alles andere bricht ab + * --iban (optional) IBAN des Kontos: wird gespeichert, die Seite zeigt sie als DE00...1234 * Dann wird bankConnections/finom mit status "aktiv" angelegt bzw. aktualisiert und financeSettings/main * mit den Standardwerten angelegt, falls es fehlt. Ohne diese Argumente werden nur die Collections angelegt. * * Usage (im Container): * docker exec -w /app webklar-kundenbereich-server node scripts/setup-finanzen.mjs \ - * --session --account --valid-until + * --session --account --valid-until [--iban ] */ import { parseArgs } from 'node:util' @@ -26,21 +28,45 @@ try { session: { type: 'string' }, account: { type: 'string' }, 'valid-until': { type: 'string' }, + iban: { type: 'string' }, }, strict: true, allowPositionals: false, }).values } catch { - console.error('Ungueltige Argumente. Erlaubt: --session --account --valid-until ') + console.error('Ungueltige Argumente. Erlaubt: --session --account --valid-until [--iban ]') process.exit(1) } +/** + * Strenger ISO-Zeitpunkt: JJJJ-MM-TT (Mitternacht UTC) oder JJJJ-MM-TTThh:mm:ss[.f]Z bzw. mit Versatz +hh:mm. Gibt es den + * Tag oder die Uhrzeit nicht (30.02., 25 Uhr), ist es kein Zeitpunkt. Sonst null. + */ +function parseIsoInstant(value) { + const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2}))?$/.exec(value) + if (!m) return null + const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])] + const [hour, minute, second] = [Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)] + const calendar = new Date(Date.UTC(year, month - 1, day)) + if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) return null + if (hour > 23 || minute > 59 || second > 59) return null + if (m[7] && m[7] !== 'Z' && (Number(m[7].slice(1, 3)) > 23 || Number(m[7].slice(4)) > 59)) return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +/** IBAN ohne Leerzeichen in Grossbuchstaben, oder null, wenn sie nicht wie eine IBAN aussieht (2 Buchstaben, 2 Ziffern, 11 bis 30 Zeichen). */ +function normalizeIban(value) { + const compact = String(value).replace(/\s+/g, '').toUpperCase() + return /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact) ? compact : null +} + const takeover = Object.values(args).some((v) => v !== undefined) let takeoverData = null if (takeover) { const session = String(args.session ?? '').trim() const account = String(args.account ?? '').trim() - const validUntil = new Date(String(args['valid-until'] ?? '').trim()) + const validUntil = parseIsoInstant(String(args['valid-until'] ?? '').trim()) if (!session || !account || !args['valid-until']) { console.error('Zur Uebernahme sind --session, --account und --valid-until zusammen noetig') process.exit(1) @@ -49,11 +75,19 @@ if (takeover) { console.error('--session und --account duerfen hoechstens 64 Zeichen lang sein') process.exit(1) } - if (Number.isNaN(validUntil.getTime())) { - console.error('--valid-until ist kein gueltiger ISO-Zeitpunkt') + if (!validUntil) { + console.error('--valid-until ist kein gueltiger ISO-Zeitpunkt (erlaubt: JJJJ-MM-TT oder JJJJ-MM-TTThh:mm:ssZ)') process.exit(1) } - takeoverData = { session, account, validUntil: validUntil.toISOString() } + let iban = '' + if (args.iban !== undefined) { + iban = normalizeIban(args.iban) + if (!iban) { + console.error('--iban ist keine gueltige IBAN') + process.exit(1) + } + } + takeoverData = { session, account, validUntil: validUntil.toISOString(), iban } } const endpoint = (process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1').replace(/\/$/, '') @@ -228,7 +262,7 @@ await ensureCollection( * financeSettings/main mit den Standardwerten anlegen, falls es fehlt. Die Sitzungs-ID wird weder * ausgegeben noch in Fehlermeldungen wiederholt. */ -async function takeOverSession({ session, account, validUntil }) { +async function takeOverSession({ session, account, validUntil, iban }) { const docs = (id) => `${col(id)}/documents` const exists = async (collectionId, documentId) => { try { @@ -245,6 +279,8 @@ async function takeOverSession({ session, account, validUntil }) { sessionId: session, accountUid: account, validUntil, + // IBAN nur, wenn angegeben (sonst bleibt eine gespeicherte stehen); die Seite zeigt nur die ersten und letzten vier Zeichen + ...(iban ? { iban, ibanMasked: `${iban.slice(0, 4)}…${iban.slice(-4)}` } : {}), // neue Freigabe: Warnstufen und alte Fehler gelten nicht mehr authState: '', warnedThresholds: '', diff --git a/test/financeSetup.test.js b/test/financeSetup.test.js index f46b55e..c23747a 100644 --- a/test/financeSetup.test.js +++ b/test/financeSetup.test.js @@ -1,6 +1,8 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' +import http from 'node:http' +import { execFile } from 'node:child_process' import { schemaKeys } from './support/schema.js' test('bankTransactions: Attribute der Spec sind im Setup-Skript angelegt', () => { @@ -37,3 +39,157 @@ test('bankTransactions: Indexe auf bookingDate (Sortierung) und status (Vormerku assert.match(block, /key: 'idx_booking', type: 'key', attributes: \['bookingDate'\]/) assert.match(block, /key: 'idx_status', type: 'key', attributes: \['status'\]/) }) + +// ---------- Uebernahme einer Sitzung (--session, --account, --valid-until, --iban) ---------- + +const SCRIPT = new URL('../scripts/setup-finanzen.mjs', import.meta.url).pathname + +/** + * Appwrite-Stub fuer das Setup-Skript: Collections, Attribute (sofort "available") und Indexe werden angenommen, + * Dokumente gespeichert (GET 404 bis zum Anlegen, POST legt an, PATCH mischt). requests haelt alle Aufrufe. + */ +async function startSetupStub() { + const requests = [] + const attributes = new Map() + const docs = new Map() + const server = http.createServer(async (req, res) => { + let raw = '' + for await (const chunk of req) raw += chunk + const body = raw ? JSON.parse(raw) : null + const { pathname } = new URL(req.url, 'http://127.0.0.1') + requests.push({ method: req.method, path: pathname, body }) + const reply = (status, payload = {}) => { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(payload)) + } + let m + if ((m = pathname.match(/\/collections\/([^/]+)\/attributes\/\w+$/)) && req.method === 'POST') { + attributes.set(m[1], [...(attributes.get(m[1]) || []), { key: body.key, status: 'available' }]) + return reply(202) + } + if ((m = pathname.match(/\/collections\/([^/]+)\/attributes$/)) && req.method === 'GET') return reply(200, { attributes: attributes.get(m[1]) || [] }) + if ((m = pathname.match(/\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/))) { + const key = `${m[1]}/${m[2] ?? body?.documentId}` + if (req.method === 'GET') return docs.has(key) ? reply(200, docs.get(key)) : reply(404, { message: 'Document not found' }) + if (req.method === 'POST') return docs.set(key, { ...body.data }) && reply(201) + if (req.method === 'PATCH') return docs.set(key, { ...docs.get(key), ...body.data }) && reply(200) + } + return reply(200) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return { url: `http://127.0.0.1:${server.address().port}/v1`, requests, docs, close: () => new Promise((resolve) => server.close(resolve)) } +} + +/** Das Setup-Skript mit Argumenten gegen den Stub laufen lassen -> { code, stdout, stderr }. */ +function runSetup(stub, args) { + return new Promise((resolve) => { + execFile( + process.execPath, + [SCRIPT, ...args], + { env: { PATH: process.env.PATH, APPWRITE_ENDPOINT: stub.url, APPWRITE_API_KEY: 'test-key' }, timeout: 30_000 }, + (error, stdout, stderr) => resolve({ code: error ? error.code : 0, stdout, stderr }) + ) + }) +} + +const TAKEOVER = ['--session', 'sess-geheim-4711', '--account', 'konto-4711'] +const connectionDoc = (stub) => stub.docs.get('bankConnections/finom') + +test('Setup --valid-until: nur ein echtes Datum oder ein voller ISO-Zeitpunkt mit Zeitzone, alles andere bricht ab (M5)', async () => { + const stub = await startSetupStub() + try { + for (const bad of ['morgen', '1', '2027', '2027-3-29', '2027-13-01', '2027-02-30', '2027-03-29 00:00:00', '2027-03-29T00:00:00', '2027-03-29T25:00:00Z', '29.03.2027', 'Mar 29 2027', '2027-03-29T00:00:00+25:00']) { + const result = await runSetup(stub, [...TAKEOVER, '--valid-until', bad]) + assert.equal(result.code, 1, `${bad}: Exit-Code ${result.code}`) + assert.match(result.stderr, /--valid-until ist kein gueltiger ISO-Zeitpunkt/, bad) + assert.ok(!result.stdout.includes('Fertig'), bad) + assert.ok(!result.stderr.includes('sess-geheim-4711')) + } + // vor jedem Aufruf der API: nichts angelegt, nichts uebernommen + assert.equal(stub.requests.length, 0) + } finally { + await stub.close() + } +}) + +test('Setup --valid-until: Datum und voller ISO-Zeitpunkt werden uebernommen und als UTC gespeichert (M5)', async () => { + const stub = await startSetupStub() + try { + const cases = [ + ['2027-03-29', '2027-03-29T00:00:00.000Z'], + ['2027-03-29T00:00:00Z', '2027-03-29T00:00:00.000Z'], + ['2027-03-29T02:00:00.000+02:00', '2027-03-29T00:00:00.000Z'], + ['2027-03-29T00:00:00.000000+00:00', '2027-03-29T00:00:00.000Z'], + ] + for (const [input, stored] of cases) { + const result = await runSetup(stub, [...TAKEOVER, '--valid-until', input]) + assert.equal(result.code, 0, `${input}: ${result.stderr}`) + assert.equal(connectionDoc(stub).validUntil, stored, input) + assert.equal(connectionDoc(stub).status, 'aktiv') + assert.ok(!result.stdout.includes('sess-geheim-4711'), 'die Sitzungs-ID wird nie ausgegeben') + } + } finally { + await stub.close() + } +}) + +test('Setup --iban: wird gespeichert, ibanMasked sind die ersten und letzten vier Zeichen (M5)', async () => { + const stub = await startSetupStub() + try { + const result = await runSetup(stub, [...TAKEOVER, '--valid-until', '2027-03-29', '--iban', 'de00 0000 0000 0000 0000 93']) + assert.equal(result.code, 0, result.stderr) + const doc = connectionDoc(stub) + assert.equal(doc.iban, 'DE00000000000000000093') + assert.equal(doc.ibanMasked, 'DE00…0093') + assert.equal(doc.sessionId, 'sess-geheim-4711') + assert.equal(doc.accountUid, 'konto-4711') + // beide Felder passen in die Schemagroessen (40) + assert.ok(doc.iban.length <= 40 && doc.ibanMasked.length <= 40) + assert.ok(!result.stdout.includes('DE0000'), 'die IBAN wird nicht ausgegeben') + + // ohne --iban bleibt eine gespeicherte IBAN stehen + const again = await runSetup(stub, [...TAKEOVER, '--valid-until', '2027-04-01']) + assert.equal(again.code, 0, again.stderr) + assert.equal(connectionDoc(stub).iban, 'DE00000000000000000093') + assert.equal(connectionDoc(stub).validUntil, '2027-04-01T00:00:00.000Z') + } finally { + await stub.close() + } +}) + +test('Setup --iban: keine IBAN -> Abbruch vor jedem API-Aufruf; --iban allein ist keine Uebernahme (M5)', async () => { + const stub = await startSetupStub() + try { + for (const bad of ['', 'abc', 'DE12', '1234567890123456', 'DE00 0000 0000 0000 0000 93 ZZZZZZZZZZZZZZZZZZZZZZZZZZ']) { + const result = await runSetup(stub, [...TAKEOVER, '--valid-until', '2027-03-29', '--iban', bad]) + assert.equal(result.code, 1, `"${bad}"`) + assert.match(result.stderr, /--iban ist keine gueltige IBAN/) + } + const alone = await runSetup(stub, ['--iban', 'DE00000000000000000093']) + assert.equal(alone.code, 1) + assert.match(alone.stderr, /--session, --account und --valid-until zusammen/) + assert.equal(stub.requests.length, 0) + } finally { + await stub.close() + } +}) + +test('Setup: ohne Uebernahme-Argumente werden nur die Collections angelegt, kein Dokument', async () => { + const stub = await startSetupStub() + try { + const result = await runSetup(stub, []) + assert.equal(result.code, 0, result.stderr) + assert.equal(stub.docs.size, 0) + assert.ok(stub.requests.some((r) => r.method === 'POST' && r.path.endsWith('/collections'))) + } finally { + await stub.close() + } +}) + +test('.env.example nennt ENABLE_BANKING_APP_ID und ENABLE_BANKING_KEY_PATH auskommentiert und ohne Wert (M16)', () => { + const lines = readFileSync(new URL('../.env.example', import.meta.url), 'utf8').split('\n') + for (const name of ['ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_KEY_PATH']) { + assert.ok(lines.includes(`# ${name}=`), `${name} fehlt oder hat einen Wert`) + assert.ok(!lines.some((line) => line.startsWith(`${name}=`)), `${name} darf nicht gesetzt sein`) + } +})