server/services/featureConsents.js speichert und liest den Nachweis zur Bestellung einer Funktion (nur anfuegen, sieben Exporte): speichereZustimmung (Texte und Fassungen als JSON, SHA-256 ueber die Texte, vollstaendig oder gar nicht), letzteZustimmung, zustimmungFuerSitzung (leere Sitzung fragt nicht ab), leseZustimmung, texteHash, zustimmungsfassungen und nachweisTexte (Haken, Belehrung, Knopf aus dem Register). paidFeatureOrders liefert featureKey und vertragAt (Klick der bezahlten Sitzung, sonst consentAt, sonst orderedAt; Lesefehler faellt mit Log auf orderedAt zurueck); der Widerruf einer Funktion rechnet die Frist aus vertragAt. Eine spaetere, unbezahlte Zustimmung verschiebt den Anker nicht. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
322 lines
16 KiB
JavaScript
322 lines
16 KiB
JavaScript
import { test, after, beforeEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createHash } from 'node:crypto'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { startFakeStripe } from './support/fakeStripe.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
|
|
// Zustimmungsnachweis je Funktion (server/services/featureConsents.js, Collection featureConsents):
|
|
// nur anfuegen und lesen. Die Umgebung steht vor dem ersten Import von server/config.js.
|
|
const aw = await startAppwriteMock({ schema: { featureConsents: schemaKeys('featureConsents') } })
|
|
const st = await startFakeStripe()
|
|
after(() => Promise.all([aw.close(), st.close()]))
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|
const { config } = await import('../server/config.js')
|
|
const consents = await import('../server/services/featureConsents.js')
|
|
const { featureConfig } = await import('../server/services/featureRegistry.js')
|
|
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
|
const { paidFeatureOrders } = await import('../server/services/siteFeatures.js')
|
|
const { speichereZustimmung, letzteZustimmung, zustimmungFuerSitzung, leseZustimmung, texteHash, zustimmungsfassungen, nachweisTexte } = consents
|
|
|
|
const COLLECTION = config.collections.featureConsents
|
|
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
|
|
|
|
const TEXTE = [
|
|
{ id: 'unterlagen', text: 'Ich stimme der Leistungsbeschreibung zu.', haken: true },
|
|
{ id: 'beginn', text: 'Ich verlange den Beginn vor Ablauf der Frist.', haken: true },
|
|
{ id: 'knopf', text: 'Zahlungspflichtig bestellen · 149 €', haken: false },
|
|
]
|
|
const FASSUNGEN = { zustimmung: 'V1', leistungsbeschreibung: 'V1', avv: 'V1' }
|
|
|
|
function eintrag(patch = {}) {
|
|
return {
|
|
customerId: 'c1',
|
|
projectId: 'p1',
|
|
featureKey: 'termine',
|
|
stripeSessionId: 'cs_test_1',
|
|
fassungen: FASSUNGEN,
|
|
texte: TEXTE,
|
|
ip: '203.0.113.7',
|
|
userAgent: 'Testbrowser/1.0',
|
|
konto: 'Kundin <kundin@example.invalid>',
|
|
at: '2026-10-03T10:00:00.000Z',
|
|
...patch,
|
|
}
|
|
}
|
|
|
|
const zeilen = () => [...aw.docs(COLLECTION).values()]
|
|
|
|
// Jeder Test beginnt mit leerer Collection (der Nachbau liefert aus docs() eine Kopie; geloescht wird ueber die API)
|
|
beforeEach(async () => {
|
|
for (const id of aw.docs(COLLECTION).keys()) await deleteDocument(COLLECTION, id)
|
|
})
|
|
|
|
test('speichert und liest: Rundlauf mit Fassungen als Objekt, Texten als Array und unabhängig berechnetem Hash', async () => {
|
|
const doc = await speichereZustimmung(eintrag())
|
|
assert.ok(doc.$id)
|
|
assert.equal(doc.customerId, 'c1')
|
|
assert.equal(doc.projectId, 'p1')
|
|
assert.equal(doc.featureKey, 'termine')
|
|
assert.equal(doc.stripeSessionId, 'cs_test_1')
|
|
assert.equal(doc.acceptedAt, '2026-10-03T10:00:00.000Z')
|
|
assert.equal(doc.ip, '203.0.113.7')
|
|
assert.equal(doc.userAgent, 'Testbrowser/1.0')
|
|
assert.equal(doc.konto, 'Kundin <kundin@example.invalid>')
|
|
// Beides liegt als JSON-Text in der Collection
|
|
assert.equal(typeof doc.fassungen, 'string')
|
|
assert.equal(typeof doc.texte, 'string')
|
|
assert.equal(doc.texte, JSON.stringify(TEXTE))
|
|
assert.equal(doc.textHash, sha256(JSON.stringify(TEXTE)))
|
|
assert.equal(texteHash(TEXTE), sha256(JSON.stringify(TEXTE)))
|
|
|
|
const gelesen = leseZustimmung(zeilen()[0])
|
|
assert.deepEqual(gelesen.fassungen, FASSUNGEN)
|
|
assert.deepEqual(gelesen.texte, TEXTE)
|
|
assert.equal(gelesen.textHash, doc.textHash)
|
|
assert.equal(gelesen.$id, doc.$id)
|
|
assert.equal(gelesen.stripeSessionId, 'cs_test_1')
|
|
// leseZustimmung ändert das gespeicherte Dokument nicht
|
|
assert.equal(typeof zeilen()[0].texte, 'string')
|
|
})
|
|
|
|
test('speichert den Zeitpunkt aus einem Date-Objekt als ISO-Text', async () => {
|
|
const doc = await speichereZustimmung(eintrag({ at: new Date('2026-10-03T12:34:56.789Z') }))
|
|
assert.equal(doc.acceptedAt, '2026-10-03T12:34:56.789Z')
|
|
})
|
|
|
|
test('append-only: das Modul exportiert genau die sieben Lese- und Anfügefunktionen', () => {
|
|
assert.deepEqual(
|
|
Object.keys(consents).sort(),
|
|
['leseZustimmung', 'letzteZustimmung', 'nachweisTexte', 'speichereZustimmung', 'texteHash', 'zustimmungFuerSitzung', 'zustimmungsfassungen']
|
|
)
|
|
assert.equal(Object.keys(consents).filter((name) => /update|delete|remove|aender|loesch/i.test(name)).length, 0)
|
|
})
|
|
|
|
test('neueste Zustimmung: nach acceptedAt, andere Funktion und anderes Projekt stören nicht', async () => {
|
|
// Reihenfolge des Anlegens ist absichtlich nicht die Zeitreihenfolge
|
|
const spaeter = await speichereZustimmung(eintrag({ stripeSessionId: 'cs_b', at: '2026-10-03T12:00:00.000Z' }))
|
|
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_a', at: '2026-10-03T09:00:00.000Z' }))
|
|
// spätere Einträge, die nicht zählen dürfen
|
|
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_n', featureKey: 'news', at: '2026-10-04T09:00:00.000Z' }))
|
|
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_p', projectId: 'p2', at: '2026-10-04T10:00:00.000Z' }))
|
|
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_c', customerId: 'c2', at: '2026-10-04T11:00:00.000Z' }))
|
|
|
|
const neueste = await letzteZustimmung({ customerId: 'c1', projectId: 'p1', featureKey: 'termine' })
|
|
assert.equal(neueste.$id, spaeter.$id)
|
|
assert.equal(neueste.stripeSessionId, 'cs_b')
|
|
|
|
assert.equal((await letzteZustimmung({ customerId: 'c1', projectId: 'p1', featureKey: 'news' })).stripeSessionId, 'cs_n')
|
|
assert.equal((await letzteZustimmung({ customerId: 'c1', projectId: 'p2', featureKey: 'termine' })).stripeSessionId, 'cs_p')
|
|
assert.equal(await letzteZustimmung({ customerId: 'c1', projectId: 'p9', featureKey: 'termine' }), null)
|
|
})
|
|
|
|
test('neueste Zustimmung: unvollständige Angaben liefern null ohne Abfrage', async () => {
|
|
await speichereZustimmung(eintrag())
|
|
aw.resetListCalls()
|
|
assert.equal(await letzteZustimmung({ customerId: 'c1', projectId: '', featureKey: 'termine' }), null)
|
|
assert.equal(await letzteZustimmung({ customerId: 'c1', featureKey: 'termine' }), null)
|
|
assert.equal(await letzteZustimmung({}), null)
|
|
assert.equal(aw.listCalls(COLLECTION).length, 0)
|
|
})
|
|
|
|
test('zustimmungFuerSitzung: findet den Nachweis genau dieser Sitzung, sonst null; Lesefehler werfen', async () => {
|
|
const a = await speichereZustimmung(eintrag({ stripeSessionId: 'cs_a', at: '2026-10-03T09:00:00.000Z' }))
|
|
const b = await speichereZustimmung(eintrag({ stripeSessionId: 'cs_b', at: '2026-10-03T12:00:00.000Z' }))
|
|
assert.equal((await zustimmungFuerSitzung('cs_a')).$id, a.$id)
|
|
assert.equal((await zustimmungFuerSitzung('cs_b')).$id, b.$id)
|
|
assert.equal(await zustimmungFuerSitzung('cs_unbekannt'), null)
|
|
|
|
aw.failNext({ collection: COLLECTION, method: 'GET', status: 500 })
|
|
await assert.rejects(() => zustimmungFuerSitzung('cs_a'))
|
|
})
|
|
|
|
test('vollständig oder gar nicht: zu lange Texte werfen 400 und es wird nichts geschrieben', async () => {
|
|
const lang = [{ id: 'x', text: 'a'.repeat(20000), haken: true }]
|
|
assert.ok(JSON.stringify(lang).length > 20000)
|
|
await assert.rejects(() => speichereZustimmung(eintrag({ texte: lang })), (err) => err.status === 400)
|
|
assert.equal(zeilen().length, 0)
|
|
|
|
// genau am Rand wird nichts gekürzt: passt der JSON-Text, kommt er vollständig an
|
|
const textLaenge = 20000 - JSON.stringify([{ id: 'x', text: '', haken: true }]).length
|
|
const passend = [{ id: 'x', text: 'b'.repeat(textLaenge), haken: true }]
|
|
assert.equal(JSON.stringify(passend).length, 20000)
|
|
const doc = await speichereZustimmung(eintrag({ texte: passend }))
|
|
assert.equal(doc.texte, JSON.stringify(passend))
|
|
assert.deepEqual(leseZustimmung(doc).texte, passend)
|
|
})
|
|
|
|
test('vollständig oder gar nicht: zu lange Fassungen, fehlendes oder ungültiges at, leere oder ungültige Texte werfen', async () => {
|
|
const vorher = zeilen().length
|
|
const bad = async (patch, status = 400) =>
|
|
assert.rejects(() => speichereZustimmung(eintrag(patch)), (err) => err.status === status, JSON.stringify(Object.keys(patch)))
|
|
|
|
await bad({ fassungen: { zustimmung: 'v'.repeat(1000) } })
|
|
await bad({ fassungen: null })
|
|
await bad({ fassungen: ['V1'] })
|
|
await bad({ fassungen: undefined })
|
|
|
|
await bad({ at: undefined })
|
|
await bad({ at: null })
|
|
await bad({ at: '' })
|
|
await bad({ at: 'gestern' })
|
|
await bad({ at: new Date('kaputt') })
|
|
await bad({ at: 1759485600000 })
|
|
|
|
await bad({ texte: [] })
|
|
await bad({ texte: undefined })
|
|
await bad({ texte: 'Text' })
|
|
await bad({ texte: [{ id: 'a', text: '' }] })
|
|
await bad({ texte: [{ id: '', text: 'Text' }] })
|
|
await bad({ texte: [{ id: 'a', text: 'Text', haken: 'ja' }] })
|
|
await bad({ texte: ['Text'] })
|
|
await bad({ texte: [null] })
|
|
|
|
await bad({ customerId: '' })
|
|
await bad({ projectId: undefined })
|
|
await bad({ featureKey: '' })
|
|
await bad({ featureKey: 'x'.repeat(17) })
|
|
await bad({ stripeSessionId: 's'.repeat(129) })
|
|
|
|
assert.equal(zeilen().length, vorher, 'nichts geschrieben')
|
|
})
|
|
|
|
test('IP, User-Agent und Konto werden auf 64, 300 und 255 Zeichen gekürzt', async () => {
|
|
const doc = await speichereZustimmung(eintrag({ ip: 'i'.repeat(100), userAgent: 'u'.repeat(400), konto: 'k'.repeat(300) }))
|
|
assert.equal(doc.ip, 'i'.repeat(64))
|
|
assert.equal(doc.userAgent, 'u'.repeat(300))
|
|
assert.equal(doc.konto, 'k'.repeat(255))
|
|
// Fehlende Angaben werden leer
|
|
const leer = await speichereZustimmung(eintrag({ ip: undefined, userAgent: null, konto: undefined, stripeSessionId: 'cs_leer' }))
|
|
assert.equal(leer.ip, '')
|
|
assert.equal(leer.userAgent, '')
|
|
assert.equal(leer.konto, '')
|
|
})
|
|
|
|
test('Nachweistexte aus dem Register: Haken in Anzeigereihenfolge, zuletzt der Knopf; Belehrung direkt davor', () => {
|
|
const termine = featureConfig('termine')
|
|
const texte = nachweisTexte(termine)
|
|
assert.equal(texte.length, 5)
|
|
assert.deepEqual(
|
|
texte.map((t) => t.id),
|
|
[...termine.consent.items.map((i) => i.id), 'knopf']
|
|
)
|
|
assert.deepEqual(texte.slice(0, 4), termine.consent.items.map((i) => ({ id: i.id, text: i.text, haken: true })))
|
|
assert.deepEqual(texte.at(-1), { id: 'knopf', text: termine.consent.knopf, haken: false })
|
|
|
|
const mit = nachweisTexte(termine, { belehrung: 'Text' })
|
|
assert.equal(mit.length, 6)
|
|
assert.deepEqual(mit.at(-2), { id: 'belehrung', text: 'Text', haken: false })
|
|
assert.deepEqual(mit.at(-1), { id: 'knopf', text: termine.consent.knopf, haken: false })
|
|
assert.deepEqual(mit.slice(0, 4), texte.slice(0, 4))
|
|
// ohne oder mit null: keine Belehrung
|
|
assert.deepEqual(nachweisTexte(termine, { belehrung: null }), texte)
|
|
|
|
// News: ein Haken und der Knopf
|
|
const news = featureConfig('news')
|
|
const newsTexte = nachweisTexte(news)
|
|
assert.deepEqual(newsTexte.map((t) => t.id), ['beginn', 'knopf'])
|
|
assert.equal(newsTexte[0].text, news.consent.items[0].text)
|
|
assert.equal(newsTexte[1].text, news.consent.knopf)
|
|
|
|
// Eine übergebene Belehrung muss ein nichtleerer Text sein (sonst fehlte ein gezeigter Text im Nachweis)
|
|
assert.throws(() => nachweisTexte(termine, { belehrung: '' }))
|
|
assert.throws(() => nachweisTexte(termine, { belehrung: { text: 'x' } }))
|
|
})
|
|
|
|
test('Fassungen aus dem Register: Zustimmung plus die vorhandenen Fassungsfelder', () => {
|
|
const news = featureConfig('news')
|
|
assert.deepEqual(zustimmungsfassungen(news), { zustimmung: news.consent.version, leistungsbeschreibung: news.leistungsbeschreibung.version })
|
|
const termine = featureConfig('termine')
|
|
assert.deepEqual(zustimmungsfassungen(termine), {
|
|
zustimmung: termine.consent.version,
|
|
leistungsbeschreibung: termine.leistungsbeschreibung.version,
|
|
avv: termine.avv.version,
|
|
})
|
|
// widerruf kommt dazu, sobald das Register es führt
|
|
const mitWiderruf = { ...termine, widerruf: { version: 'W1', url: '/x' } }
|
|
assert.deepEqual(zustimmungsfassungen(mitWiderruf), {
|
|
zustimmung: termine.consent.version,
|
|
leistungsbeschreibung: termine.leistungsbeschreibung.version,
|
|
avv: termine.avv.version,
|
|
widerruf: 'W1',
|
|
})
|
|
// Das Ergebnis passt in den Nachweis
|
|
assert.ok(JSON.stringify(zustimmungsfassungen(mitWiderruf)).length <= 1000)
|
|
})
|
|
|
|
test('Staff-Nachweis: ohne Sitzung, IP, User-Agent und Haken gespeichert; zustimmungFuerSitzung("") fragt nicht ab', async () => {
|
|
const doc = await speichereZustimmung(
|
|
eintrag({
|
|
stripeSessionId: '',
|
|
ip: '',
|
|
userAgent: '',
|
|
konto: 'Mitarbeiter: Kenso',
|
|
fassungen: { leistungsbeschreibung: 'V1', avv: 'V1' },
|
|
texte: [{ id: 'textform', text: 'Der Kunde hat in Textform zugestimmt.' }],
|
|
})
|
|
)
|
|
assert.equal(doc.stripeSessionId, '')
|
|
assert.equal(doc.ip, '')
|
|
assert.equal(doc.userAgent, '')
|
|
assert.equal(doc.konto, 'Mitarbeiter: Kenso')
|
|
assert.deepEqual(leseZustimmung(doc).texte, [{ id: 'textform', text: 'Der Kunde hat in Textform zugestimmt.' }])
|
|
assert.equal(doc.textHash, sha256(JSON.stringify([{ id: 'textform', text: 'Der Kunde hat in Textform zugestimmt.' }])))
|
|
|
|
// Die Zeile mit leerer Sitzung existiert, darf aber über keine Sitzungsabfrage getroffen werden
|
|
assert.equal(zeilen().length, 1)
|
|
aw.resetListCalls()
|
|
assert.equal(await zustimmungFuerSitzung(''), null)
|
|
assert.equal(await zustimmungFuerSitzung(undefined), null)
|
|
assert.equal(await zustimmungFuerSitzung(null), null)
|
|
assert.equal(await zustimmungFuerSitzung(' '), null)
|
|
assert.equal(aw.listCalls(COLLECTION).length, 0, 'keine Listenabfrage an featureConsents')
|
|
|
|
// und sie steht als neueste Zustimmung des Projekts zur Verfügung
|
|
assert.equal((await letzteZustimmung({ customerId: 'c1', projectId: 'p1', featureKey: 'termine' })).$id, doc.$id)
|
|
})
|
|
|
|
test('texteHash: SHA-256 über JSON.stringify der Texte in der gegebenen Reihenfolge', () => {
|
|
const umgekehrt = [...TEXTE].reverse()
|
|
assert.notEqual(texteHash(umgekehrt), texteHash(TEXTE))
|
|
assert.match(texteHash(TEXTE), /^[0-9a-f]{64}$/)
|
|
})
|
|
|
|
test('paidFeatureOrders: featureKey und vertragAt (Nachweis zur bezahlten Sitzung, sonst consentAt, sonst orderedAt)', async () => {
|
|
const gebucht = (patch) => ({ customerId: 'c1', enabled: false, unlockedByPurchase: true, ...patch })
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_a', projectId: 'pa', featureKey: 'termine', state: 'setup', orderedAt: '2026-10-03T08:00:00.000Z', consentAt: '2026-10-01T08:00:00.000Z', stripeSessionId: 'cs_bezahlt' }))
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_b', projectId: 'pb', featureKey: 'news', state: 'active', orderedAt: '2026-09-30T08:00:00.000Z', consentAt: '2026-09-29T08:00:00.000Z', stripeSessionId: 'cs_alt' }))
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_c', projectId: 'pc', featureKey: 'news', state: 'disabled', orderedAt: '2026-09-28T08:00:00.000Z', stripeSessionId: '' }))
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_d', projectId: 'pd', featureKey: 'termine', state: 'ordered', orderedAt: '2026-10-03T09:00:00.000Z', unlockedByPurchase: false, stripeSessionId: 'cs_offen' }))
|
|
aw.seed('portalFeatures', gebucht({ $id: 'f_e', customerId: 'c2', projectId: 'pe', featureKey: 'termine', state: 'setup', orderedAt: '2026-10-03T09:00:00.000Z', stripeSessionId: 'cs_fremd' }))
|
|
// Klick der bezahlten Sitzung; dazu eine spätere, unbezahlte Zustimmung desselben Kunden/Projekts
|
|
await speichereZustimmung(eintrag({ projectId: 'pa', stripeSessionId: 'cs_bezahlt', at: '2026-09-20T10:00:00.000Z' }))
|
|
await speichereZustimmung(eintrag({ projectId: 'pa', stripeSessionId: 'cs_zweite', at: '2026-10-03T07:00:00.000Z' }))
|
|
try {
|
|
aw.resetListCalls()
|
|
const orders = await paidFeatureOrders('c1')
|
|
assert.deepEqual(
|
|
orders.map((o) => [o.featureKey, o.state, o.vertragAt]),
|
|
[
|
|
['termine', 'setup', '2026-09-20T10:00:00.000Z'],
|
|
['news', 'active', '2026-09-29T08:00:00.000Z'],
|
|
['news', 'disabled', '2026-09-28T08:00:00.000Z'],
|
|
]
|
|
)
|
|
assert.deepEqual(orders.map((o) => o.orderedAt), ['2026-10-03T08:00:00.000Z', '2026-09-30T08:00:00.000Z', '2026-09-28T08:00:00.000Z'])
|
|
|
|
// Nachweis-Collection nicht lesbar: Rückfall auf orderedAt (nicht auf das Zustimmungsdatum), kein Fehler
|
|
const warn = console.warn
|
|
console.warn = () => {}
|
|
try {
|
|
aw.failNext({ collection: COLLECTION, method: 'GET', status: 500 })
|
|
const kaputt = await paidFeatureOrders('c1')
|
|
assert.deepEqual(kaputt.map((o) => o.vertragAt), ['2026-10-03T08:00:00.000Z', '2026-09-29T08:00:00.000Z', '2026-09-28T08:00:00.000Z'])
|
|
} finally {
|
|
console.warn = warn
|
|
}
|
|
} finally {
|
|
for (const id of ['f_a', 'f_b', 'f_c', 'f_d', 'f_e']) await deleteDocument(config.collections.portalFeatures, id)
|
|
}
|
|
})
|