diff --git a/scripts/unterkunft-setup.mjs b/scripts/unterkunft-setup.mjs index ed0ed65..39f4566 100644 --- a/scripts/unterkunft-setup.mjs +++ b/scripts/unterkunft-setup.mjs @@ -44,6 +44,20 @@ export async function main({ check = false, client, log = console.log } = {}) { plan.push({ id, exists, absentAttributes, absentIndexes }) } + // Rechnungs-PDF an Gastmails: termineOutbox braucht das Attribut `anhang` (Schema des Terminplaners). Fehlt es, meldet --check das (Exit 1) und + // der normale Lauf legt es an. Fehlt die ganze Collection, ist das ein Fall fuer scripts/termine-setup.mjs (nur Hinweis). + const outboxId = config.collections.termineOutbox + const anhang = { key: 'anhang', type: 'string', size: 500, required: false } + if (await api.collectionExists(outboxId)) { + if (!(await api.listAttributes(outboxId)).some((a) => a.key === 'anhang')) { + if (check) log(`- ${outboxId}.anhang FEHLT`) + missing.push({ ...anhang, collection: outboxId }) + plan.push({ id: outboxId, exists: true, absentAttributes: [anhang], absentIndexes: [] }) + } + } else { + log(`! ${outboxId} fehlt: zuerst scripts/termine-setup.mjs ausfuehren (Gastmails mit Rechnungs-PDF brauchen termineOutbox.anhang)`) + } + const result = { missing, missingCollections, missingIndexes } if (check) { const open = missing.length + missingCollections.length + missingIndexes.length diff --git a/server/routes/publicUnterkunft.js b/server/routes/publicUnterkunft.js index c383f47..271552e 100644 --- a/server/routes/publicUnterkunft.js +++ b/server/routes/publicUnterkunft.js @@ -331,8 +331,10 @@ router.post( const pruefung = pruefeFormToken(siteKey, b.formToken, now) if (pruefung === 'zu_schnell') return { status: 201, body: { nummer: scheinNummer() } } if (pruefung !== 'ok') throw fehlerMit(400, 'neu_laden') - if (!zaehle(`uk-anfrage:${siteKey}`, config.unterkunft.anfragenJeSiteStunde, STUNDE_MS, now)) throw zuViele(3600) if (!ID_RE.test(text(b.unterkunft))) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' }) + if (!istTag(b.von) || !istTag(b.bis)) throw ungueltig({ zeitraum: 'Ungültige Daten.' }) + // je Website erst nach der Feldpruefung zaehlen: fehlerhafte Formulare verbrauchen das Stundenkontingent nicht + if (!zaehle(`uk-anfrage:${siteKey}`, config.unterkunft.anfragenJeSiteStunde, STUNDE_MS, now)) throw zuViele(3600) const personen = typeof b.personen === 'string' && /^\d{1,3}$/.test(b.personen) ? Number(b.personen) : b.personen const anfrage = await legeAnfrageAn( ctx, diff --git a/server/routes/unterkunft.js b/server/routes/unterkunft.js index c86eecb..4cba212 100644 --- a/server/routes/unterkunft.js +++ b/server/routes/unterkunft.js @@ -12,7 +12,7 @@ import { istFrei } from '../services/unterkunft/belegung.js' import { istTag, naechte } from '../services/unterkunft/datum.js' import { anfrageAnsicht as funktionAnfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/unterkunft/anfrageFunktion.js' import { UkFehler } from '../services/unterkunft/fehler.js' -import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' +import { ladeFuerProjekt, leereCache } from '../services/unterkunft/kontext.js' import { berechnePreis, preiseVollstaendig, summiere } from '../services/unterkunft/preis.js' import { unterkunftFuerKunde } from '../services/unterkunft/schalter.js' import * as sp from '../services/unterkunft/speicher.js' @@ -330,6 +330,8 @@ router.put('/einstellungen', ...schreib, route(async (req, res) => { if (rechnungAuftrag === false) werte.rechnungAuftragAt = null return sp.speichereEinstellungen(ctx.featureId, werte) }) + // die oeffentliche Schnittstelle (Farbe, Firma, Online-Zahlung) merkt sich den Kontext 30 s: sofort frisch + if (ctx.siteKey) leereCache(ctx.siteKey) res.json(await einstellungenAntwort(ctx, neu)) })) diff --git a/server/routes/unterkunftWebhook.js b/server/routes/unterkunftWebhook.js index 24ef671..6ca0e4c 100644 --- a/server/routes/unterkunftWebhook.js +++ b/server/routes/unterkunftWebhook.js @@ -6,12 +6,12 @@ import { statusAusKonto } from '../services/shop/connect.js' import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' import { unterkunftModus } from '../services/unterkunft/schalter.js' import { aktualisiereAnfrage, ladeAnfrage, ladeEinstellungen } from '../services/unterkunft/speicher.js' -import { verbucheZahlung } from '../services/unterkunft/zahlung.js' +import { erstatteUeberzaehligeZahlung, verbucheZahlung, zahlungPasst } from '../services/unterkunft/zahlung.js' /** * Stripe-Ereignisse des Buchungskalenders. Kein eigener Endpunkt: routes/shopWebhook.js (Connect-Webhook, ein Endpunkt je Konto) * leitet Ereignisse mit metadata.wkUk === '1' (Checkout-Sitzungen) oder metadata.webklar === 'unterkunft' (Erstattungen) hierher - * und ruft bei account.updated zusaetzlich kontoAktualisiertUk. Alles idempotent; Fehler => 500 (Stripe wiederholt). + * und ruft bei account.updated zusaetzlich kontoAktualisiertUk. Alles idempotent; ein Fehler bei Checkout- und Erstattungsereignissen => 500 (Stripe wiederholt), bei account.updated nur ein Log-Eintrag (shopWebhook.js). */ const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/ @@ -48,11 +48,12 @@ async function zahlungEingegangen(event, session, now) { const treffer = await anfrageZu(event, session.metadata?.anfrageId) if (!treffer) return const { anfrage, ctx } = treffer - const brutto = anfrage.summen?.bruttoCent - if (Number.isInteger(session.amount_total) && Number.isInteger(brutto) && session.amount_total !== brutto) { - console.warn(`[unterkunft] Webhook: Betrag weicht ab (Buchung ${anfrage.nummer})`) - } const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id + if (!zahlungPasst(anfrage, session)) { + console.warn(`[unterkunft] Webhook: Betrag oder Währung weicht ab (Buchung ${anfrage.nummer}), Zahlung wird erstattet`) + await erstatteUeberzaehligeZahlung(ctx, pi) + return + } await verbucheZahlung(ctx, anfrage.$id, pi, { now }) } @@ -60,7 +61,8 @@ async function erstattungGebucht(event, charge) { const treffer = await anfrageZu(event, charge.metadata?.anfrageId) if (!treffer) return const { anfrage } = treffer - if (anfrage.stripePaymentIntentId && typeof charge.payment_intent === 'string' && charge.payment_intent !== anfrage.stripePaymentIntentId) return + // nur die Zahlung, die diese Buchung bezahlt hat (fremde oder ueberzaehlige Zahlungen mit gleicher anfrageId zaehlen nicht) + if (!anfrage.stripePaymentIntentId || typeof charge.payment_intent !== 'string' || charge.payment_intent !== anfrage.stripePaymentIntentId) return const stand = charge.amount_refunded if (!Number.isInteger(stand) || stand <= 0) return await withLocks(['unterkunft:' + anfrage.unterkunftId], async () => { diff --git a/server/services/unterkunft/ablauf.js b/server/services/unterkunft/ablauf.js index 45c5269..5cfddb9 100644 --- a/server/services/unterkunft/ablauf.js +++ b/server/services/unterkunft/ablauf.js @@ -6,7 +6,7 @@ import { istTag, naechte } from './datum.js' import { istFrei } from './belegung.js' import { UkFehler } from './fehler.js' import * as mails from './mails.js' -import { berechnePreis, preiseVollstaendig, pruefePosten, summiere } from './preis.js' +import { BRUTTO_MAX_CENT, berechnePreis, preiseVollstaendig, pruefePosten, summiere } from './preis.js' import { stelleAus } from './rechnungen.js' import { aktualisiereAnfrage, ladeAnfrage, findeRechnung, ladeEinstellungen, ladePreise, ladeRechnung, ladeUnterkunft, legeAnfrageDokAn, listeAnfragen, listeAnfragenAlle } from './speicher.js' import { linkGueltig, neuesToken, tokenPasst } from './token.js' @@ -137,6 +137,7 @@ export async function gibFrei(ctx, anfrageId, { posten, fristTage, nachricht, no const steuerart = preise?.steuerart ?? 'regel' const summen = { ...summiere(sauber, steuerart), steuerart } if (!(summen.bruttoCent > 0)) throw new UkFehler(400, 'ungueltig', { posten: 'Die Summe muss größer als 0 sein.' }) + if (summen.bruttoCent > BRUTTO_MAX_CENT) throw new UkFehler(400, 'ungueltig', { posten: 'Die Summe ist zu hoch.' }) if (!(await istFrei(a.unterkunftId, a.von, a.bis, { ohneAnfrage: a.$id, now }))) throw new UkFehler(409, 'zeitraum_belegt') const tage = Number.isInteger(fristTage) && fristTage >= 1 ? fristTage : preise?.fristTage || 3 const { token, hash } = neuesToken() diff --git a/server/services/unterkunft/preis.js b/server/services/unterkunft/preis.js index 41fb925..350dc63 100644 --- a/server/services/unterkunft/preis.js +++ b/server/services/unterkunft/preis.js @@ -62,6 +62,9 @@ export function summiere(posten, steuerart) { return { bruttoCent, nettoCent, ust } } +/** Obergrenze der Bruttosumme einer Anfrage (999.999,99 EUR): darunter bleiben Stripe-Betrag und Rechnung sicher darstellbar. */ +export const BRUTTO_MAX_CENT = 99_999_999 + export function pruefePosten(posten, steuerart) { const fehler = (m) => ({ fehler: { posten: m } }) const maxPosten = config.unterkunft?.maxPosten ?? 30 @@ -79,7 +82,9 @@ export function pruefePosten(posten, steuerart) { if (steuerart === 'kleinunternehmer' && p.ustProzent !== 0) return fehler('Bei Kleinunternehmern ist keine Umsatzsteuer erlaubt.') sauber.push({ text, menge: p.menge, einzelCent: p.einzelCent, ustProzent: p.ustProzent }) } - if (sauber.reduce((a, p) => a + p.menge * p.einzelCent, 0) <= 0) return fehler('Die Summe muss größer als 0 sein.') + const summe = sauber.reduce((a, p) => a + p.menge * p.einzelCent, 0) + if (summe <= 0) return fehler('Die Summe muss größer als 0 sein.') + if (summe > BRUTTO_MAX_CENT) return fehler('Die Summe ist zu hoch.') return { posten: sauber } } diff --git a/server/services/unterkunft/zahlung.js b/server/services/unterkunft/zahlung.js index cc55c8a..6219d1e 100644 --- a/server/services/unterkunft/zahlung.js +++ b/server/services/unterkunft/zahlung.js @@ -91,6 +91,12 @@ export async function beendeOffeneZahlung(ctx, anfrage) { } } +/** Stimmt eine bezahlte Sitzung mit der Buchung ueberein: Betrag = Brutto der Anfrage (Cent), Waehrung eur? Sonst gilt sie nicht als Bezahlung. */ +export function zahlungPasst(anfrage, session) { + const brutto = anfrage?.summen?.bruttoCent + return Number.isInteger(brutto) && session?.amount_total === brutto && String(session?.currency ?? '').toLowerCase() === 'eur' +} + /** Volle Rueckerstattung einer Zahlung, die zu keiner offenen Buchung mehr passt (nach Storno, Ablauf oder doppelt gezahlt). */ export async function erstatteUeberzaehligeZahlung(ctx, paymentIntentId) { const accountId = ctx?.einstellungen?.stripeAccountId @@ -153,6 +159,11 @@ export async function schliesseAbWennBezahlt(ctx, anfrage, { now = new Date(), e merkeAbgleich(schluessel, { at: nowMs, abgelaufen: session?.status === 'expired' }) if (session?.status === 'complete' && session.payment_status === 'paid') { const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id + if (!zahlungPasst(anfrage, session)) { + console.warn(`[unterkunft] Abgleich: Betrag oder Währung weicht ab (Buchung ${anfrage.nummer}), Zahlung wird erstattet`) + await erstatteUeberzaehligeZahlung(ctx, pi) + return anfrage + } return await verbucheZahlung(ctx, anfrage.$id, pi, { now }) } } catch (err) { diff --git a/server/services/zeiten/mails.js b/server/services/zeiten/mails.js index 8ff5742..e6d99a1 100644 --- a/server/services/zeiten/mails.js +++ b/server/services/zeiten/mails.js @@ -22,7 +22,7 @@ const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g /** Adresse des Editors im Kundenportal. */ const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#zeiten` -/** Anrede-Wert: Name aus einem String oder einem Kundendokument, getrimmt; sonst '' (die Vorlage sagt dann „Hallo ,“, wie K22/K23). */ +/** Anrede-Wert: Name aus einem String oder einem Kundendokument, getrimmt; sonst '' (renderMail macht aus „Hallo ,“ dann „Hallo,“, wie bei K22/K23). */ function kundenName(kunde) { const name = typeof kunde === 'string' ? kunde : kunde?.name return typeof name === 'string' ? name.trim() : '' diff --git a/test/unterkunftBuchung.test.js b/test/unterkunftBuchung.test.js index 1abb570..8703b3a 100644 --- a/test/unterkunftBuchung.test.js +++ b/test/unterkunftBuchung.test.js @@ -223,7 +223,7 @@ test('Webhook: unbezahlt, fremdes Konto, unbekannte Anfrage ohne Wirkung; async_ const { id, token } = await freigegeben() await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) const sid = (await sp.ladeAnfrage(id)).stripeSessionId - const unbezahlt = { ...st.completeSession(ACCOUNT, sid, { payment_status: 'unpaid', payment_intent: 'pi_2' }) } + const unbezahlt = { ...st.completeSession(ACCOUNT, sid, { payment_status: 'unpaid', amount_total: 20000, payment_intent: 'pi_2' }) } await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: unbezahlt } }, NOW) assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht') const bezahlt = { ...unbezahlt, payment_status: 'paid' } @@ -257,7 +257,7 @@ test('POST zahlung: neue Sitzung fuer gebuchte Online-Zahlung; vor Ort oder beza assert.match(r.body.url, /^https:\/\/checkout\.stripe\.test\//) assert.equal((await post(url(id, 'falsch', '/zahlung'), {})).status, 404) const sid = (await sp.ladeAnfrage(id)).stripeSessionId - st.completeSession(ACCOUNT, sid, { payment_status: 'paid', payment_intent: 'pi_4' }) + st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_4' }) const nach = await post(url(id, token, '/zahlung'), {}) assert.equal(nach.status, 409) assert.equal((await sp.ladeAnfrage(id)).status, 'bezahlt') @@ -329,6 +329,42 @@ test('Erstattung: Stripe ging durch, Speichern scheiterte, KEIN Webhook -> Wiede assert.equal(erstattungen('pi_h2').reduce((s, x) => s + x.amount, 0), 20000) }) +test('Zahlung mit falschem Betrag oder falscher Waehrung gilt nicht als Bezahlung: Warnung, automatisch voll erstattet, Buchung bleibt gebucht (Webhook und Abgleich)', async (t) => { + t.mock.method(console, 'warn', () => {}) + const f1 = await freigegeben('2026-11-10', '2026-11-12') + await post(url(f1.id, f1.token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) + const sid1 = (await sp.ladeAnfrage(f1.id)).stripeSessionId + st.setPaymentIntent(ACCOUNT, 'pi_falsch', { amount: 100 }) + const zuWenig = st.completeSession(ACCOUNT, sid1, { payment_status: 'paid', amount_total: 100, payment_intent: 'pi_falsch' }) + await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: zuWenig } }, NOW) + assert.equal((await sp.ladeAnfrage(f1.id)).status, 'gebucht') + assert.equal(erstattungen('pi_falsch').length, 1) + assert.equal(erstattungen('pi_falsch')[0].amount, 100) + // andere Waehrung + const f2 = await freigegeben('2026-12-10', '2026-12-12') + await post(url(f2.id, f2.token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) + const sid2 = (await sp.ladeAnfrage(f2.id)).stripeSessionId + st.setPaymentIntent(ACCOUNT, 'pi_usd', { amount: 20000 }) + const usd = { ...st.completeSession(ACCOUNT, sid2, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_usd' }), currency: 'usd' } + await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: usd } }, NOW) + assert.equal((await sp.ladeAnfrage(f2.id)).status, 'gebucht') + assert.equal(erstattungen('pi_usd').length, 1) +}) + +test('Webhook charge.refunded: ohne gespeicherte Zahlung der Buchung oder mit fremder Zahlung keine Wirkung', async () => { + const { id, ctx } = await bezahlt('pi_r1') + await ab.storniere(ctx, id, { now: NOW }) + const charge = (pi, betrag) => ({ type: 'charge.refunded', account: ACCOUNT, data: { object: { id: 'ch_r', payment_intent: pi, amount_refunded: betrag, metadata: { wkUk: '1', webklar: 'unterkunft', anfrageId: id } } } }) + await W.verarbeiteUkEreignis(charge('pi_fremd', 5000), NOW) + assert.equal((await sp.ladeAnfrage(id)).erstattetCent ?? 0, 0) + await sp.aktualisiereAnfrage(id, { stripePaymentIntentId: '' }) + await W.verarbeiteUkEreignis(charge('pi_r1', 5000), NOW) + assert.equal((await sp.ladeAnfrage(id)).erstattetCent ?? 0, 0, 'Buchung ohne gespeicherte Zahlung') + await sp.aktualisiereAnfrage(id, { stripePaymentIntentId: 'pi_r1' }) + await W.verarbeiteUkEreignis(charge('pi_r1', 5000), NOW) + assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 5000) +}) + test('Webhook charge.refunded: erstattetCent = Maximum, nie zurueck', async () => { const { id, ctx } = await bezahlt('pi_8') await ab.storniere(ctx, id, { now: NOW }) diff --git a/test/unterkunftPreis.test.js b/test/unterkunftPreis.test.js index 04d9d28..61b7201 100644 --- a/test/unterkunftPreis.test.js +++ b/test/unterkunftPreis.test.js @@ -121,6 +121,8 @@ test('pruefePosten', () => { assert.ok(pruefePosten([{ ...ok, text: 'x'.repeat(201) }], 'regel').fehler.posten) assert.ok(pruefePosten(Array(31).fill(ok), 'regel').fehler.posten) assert.ok(pruefePosten([ok], 'kleinunternehmer').fehler.posten) + assert.ok(pruefePosten([{ ...ok, menge: 1, einzelCent: 10_000_000 }, ...Array(10).fill({ ...ok, menge: 1, einzelCent: 10_000_000 })], 'regel').fehler.posten, 'Summe ueber 999.999,99 EUR') + assert.ok(pruefePosten([{ ...ok, menge: 1, einzelCent: 9_999_999 }], 'regel').posten) assert.ok(pruefePosten([{ ...ok, ustProzent: 5 }], 'regel').fehler.posten) assert.ok(pruefePosten([{ ...ok, menge: 0 }], 'regel').fehler.posten) assert.ok(pruefePosten([{ ...ok, menge: 1.5 }], 'regel').fehler.posten) diff --git a/test/unterkunftPublic.test.js b/test/unterkunftPublic.test.js index da657b3..aa8d3b5 100644 --- a/test/unterkunftPublic.test.js +++ b/test/unterkunftPublic.test.js @@ -209,6 +209,13 @@ test('Anfrage: mehr als anfragenJeSiteStunde je Seite -> 429', async () => { assert.equal(aw.docs('ukAnfragen').size, 2) }) +test('Anfrage: fehlerhafte Felder (Datum, Unterkunft) verbrauchen das Stundenkontingent der Seite nicht', async () => { + config.unterkunft.anfragenJeSiteStunde = 1 + for (const extra of [{ von: 'morgen' }, { bis: '' }, { unterkunft: 'a/b' }]) assert.equal((await post(anfrage(extra))).status, 400) + assert.equal((await post(anfrage({ von: '2026-11-10', bis: '2026-11-12' }))).status, 201) + assert.equal((await post(anfrage({ von: '2026-12-10', bis: '2026-12-12' }))).status, 429) +}) + test('CORS: Preflight erlaubt/fremd, POST von fremder oder fehlender Origin -> 403', async () => { const ok = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: ORIGIN, 'access-control-request-method': 'POST' } }) assert.equal(ok.status, 204) diff --git a/test/unterkunftSetup.test.js b/test/unterkunftSetup.test.js index 7730c3a..6c47479 100644 --- a/test/unterkunftSetup.test.js +++ b/test/unterkunftSetup.test.js @@ -233,3 +233,22 @@ test('main --check meldet bei vollstaendigem Stand nichts und ein einzelnes Fehl assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), ['ukAnfragen.anonymisiertAt']) assert.deepEqual(result.missingIndexes.map((i) => `${i.collection}.${i.key}`), ['ukSperren.idx_unterkunft_quelle']) }) + +test('main: fehlendes termineOutbox.anhang wird gemeldet (--check, Exit-relevant) und im normalen Lauf angelegt; fehlende Collection nur ein Hinweis', async () => { + const client = fakeClient({ [config.collections.termineOutbox]: { attrs: ['an'] } }) + await main({ client, log: quiet }) + client.state.get(config.collections.termineOutbox).attrs.delete('anhang') + const lines = [] + const result = await main({ check: true, client, log: (l) => lines.push(l) }) + assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), [`${config.collections.termineOutbox}.anhang`]) + assert.ok(lines.some((l) => l.includes('anhang') && l.includes('FEHLT'))) + await main({ client, log: quiet }) + assert.ok(client.state.get(config.collections.termineOutbox).attrs.has('anhang')) + assert.deepEqual((await main({ check: true, client, log: quiet })).missing, []) + // Collection fehlt: Hinweis, kein Eintrag in missing + const leer = fakeClient() + const hinweise = [] + const r = await main({ check: true, client: leer, log: (l) => hinweise.push(l) }) + assert.ok(hinweise.some((l) => l.includes('termine-setup'))) + assert.ok(!r.missing.some((a) => a.key === 'anhang')) +}) diff --git a/test/unterkunftVerwaltung.test.js b/test/unterkunftVerwaltung.test.js index 351a8d8..066cdbb 100644 --- a/test/unterkunftVerwaltung.test.js +++ b/test/unterkunftVerwaltung.test.js @@ -99,6 +99,14 @@ test('Zugriff: ohne Sitzung 401, fremdes Projekt 403, Funktion aus 409, Kundenan assert.equal((await post('/unterkuenfte', { name: 'X', maxPersonen: 2 })).status, 409) }) +test('PUT /einstellungen leert den Speicher der oeffentlichen Schnittstelle: neue Firma sofort sichtbar', async () => { + const K = await import('../server/services/unterkunft/kontext.js') + K.leereCache() + assert.equal((await K.unterkunftPerSiteKey('abcdefghijklmnop')).einstellungen.firma, 'Donau Home') + assert.equal((await put('/einstellungen', { ...EINST, firma: 'Neue Firma' })).status, 200) + assert.equal((await K.unterkunftPerSiteKey('abcdefghijklmnop')).einstellungen.firma, 'Neue Firma') +}) + test('Zugriff: Schalter off sperrt die Verwaltung', async () => { config.unterkunft.mode = 'off' assert.equal((await get('/unterkuenfte')).status, 409)