From 78170567b42307032232eb4f21fba9d48fc0163b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:42:35 +0000 Subject: [PATCH] =?UTF-8?q?Analyse:=20alle=20Beendigungswege=20schalten=20?= =?UTF-8?q?den=20Messdienst=20ab=20(Kunde/Projekt=20weg,=20Domain,=20L?= =?UTF-8?q?=C3=B6schfrist,=20Widerruf=20Wechsel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite. analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged, analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg, Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen). Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte). Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable. Co-Authored-By: Claude Opus 5.5 --- analytics/src/server.js | 12 ++ analytics/test/sites.test.js | 41 +++++ server/index.js | 6 +- server/routes/abo.js | 9 + server/services/analyse.js | 260 +++++++++++++++++++++++------ server/services/analyseTexts.js | 3 +- server/services/analyticsClient.js | 9 + server/services/customerAdmin.js | 8 + server/services/giteaAdmin.js | 8 + server/services/siteFeatures.js | 7 +- test/analyse.test.js | 189 ++++++++++++++++++++- test/analyseRoutes.test.js | 18 +- test/analyticsClient.test.js | 12 ++ test/analyticsContract.test.js | 12 ++ test/giteaAdmin.test.js | 31 ++++ test/publicForms.test.js | 57 +++++++ test/retention.test.js | 4 +- 17 files changed, 616 insertions(+), 70 deletions(-) diff --git a/analytics/src/server.js b/analytics/src/server.js index 9de94e6..91205e0 100644 --- a/analytics/src/server.js +++ b/analytics/src/server.js @@ -110,6 +110,18 @@ function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged return send(res, 200, { ok: true }); } + // Beenden ohne Domainliste: active=0 und offSince einmalig (Portal: stopSite) + if (role === 'admin' && method === 'POST' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 5 && parts[4] === 'off') { + if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' }); + const key = parts[3]; + if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' }); + const old = db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(key); + if (!old) return send(res, 404, { error: 'Website nicht gefunden' }); + db.prepare('UPDATE sites SET active = 0, offSince = ? WHERE siteKey = ?').run(old.offSince || new Date().toISOString(), key); + onSitesChanged?.(); + return send(res, 200, { ok: true }); + } + if (role === 'admin' && method === 'GET' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'stats' && parts.length === 4) { if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' }); const key = parts[3]; diff --git a/analytics/test/sites.test.js b/analytics/test/sites.test.js index c600457..b2697d8 100644 --- a/analytics/test/sites.test.js +++ b/analytics/test/sites.test.js @@ -166,3 +166,44 @@ test('Datei-DB: zweimal öffnen (idempotent) und WAL', () => { assert.equal(d2.prepare('PRAGMA journal_mode').get().journal_mode, 'wal'); d2.close(); }); + +test('POST …/off: 401 ohne Token, 404 unbekannt, setzt active=0 und offSince einmalig', async () => { + const t = await setup(); + assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, null)).status, 401); + assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, 'falsch')).status, 401); + assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 404); + assert.equal((await t.call('POST', '/a/admin/sites/kurz/off')).status, 400); + await t.call('PUT', `/a/admin/sites/${KEY}`, valid); + assert.equal(row(t.db).active, 1); + const r = await t.call('POST', `/a/admin/sites/${KEY}/off`); + assert.equal(r.status, 200); + const s1 = row(t.db); + assert.equal(s1.active, 0); + assert.match(s1.offSince, /^\d{4}-\d\d-\d\dT/); + assert.deepEqual(JSON.parse(s1.domains), ['abc.project.webklar.com']); + t.db.prepare('UPDATE sites SET offSince = ? WHERE siteKey = ?').run('2026-01-01T00:00:00.000Z', KEY); + assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 200); + assert.equal(row(t.db).offSince, '2026-01-01T00:00:00.000Z'); + t.close(); +}); + +test('POST …/off: ruft onSitesChanged, danach verwirft /a/e Ereignisse und w.js ist leer', async () => { + const db = openDb(':memory:'); + const pub = createPublicServer({ config: {}, db }); + const adm = createAdminServer({ config: { adminToken: 't' }, db, onSitesChanged: () => pub.invalidateSites() }); + await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r)))); + const pb = `http://127.0.0.1:${pub.address().port}`; + const ab = `http://127.0.0.1:${adm.address().port}`; + await fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active: true }) }); + const ev = () => fetch(`${pb}/a/e`, { method: 'POST', headers: { 'Content-Type': 'text/plain', Origin: 'https://abc.project.webklar.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0) Chrome/120' }, + body: JSON.stringify({ t: 'pv', site: KEY, path: '/', vp: 'd' }) }); + await ev(); + const before = db.prepare('SELECT COUNT(*) c FROM events_raw').get().c; + assert.equal(before, 1); + assert.equal((await fetch(`${ab}/a/admin/sites/${KEY}/off`, { method: 'POST', headers: { Authorization: 'Bearer t' } })).status, 200); + await ev(); + assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw').get().c, before); + assert.equal((await (await fetch(`${pb}/a/w.js?s=${KEY}`)).text()).length < 200, true); + pub.close(); adm.close(); +}); diff --git a/server/index.js b/server/index.js index 79a5731..f0bf0ab 100644 --- a/server/index.js +++ b/server/index.js @@ -22,7 +22,7 @@ import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' -import { runAboCheck } from './services/analyse.js' +import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' import adminGiteaRoutes from './routes/admin/gitea.js' @@ -182,6 +182,10 @@ const server = app.listen(config.port, '0.0.0.0', () => { runAboCheck({ now: new Date() }) .then((r) => r.deactivated.length && console.log(`[analyse] ${r.deactivated.length} Analyse(n) ohne Abo beendet`)) .catch((err) => console.warn('[analyse] Abo-Prüfung fehlgeschlagen:', err.message)) + // danach der Abgleich: gelöschte Projekte/Kunden, geänderte Domains, Messdienst für beendete Analysen, fehlende Tickets + .then(() => runAnalyseReconcile({ now: new Date() })) + .then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`)) + .catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message)) setTimeout(analyseAboCheck, 2 * 60 * 1000).unref() setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref() diff --git a/server/routes/abo.js b/server/routes/abo.js index fe0e4b6..e581f12 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -1048,6 +1048,15 @@ async function withdrawOrder(sub, d, receivedAt) { } } } + if (inTime && type === 'wechsel') { + // Ohne Wartungs-Abo gibt es keine Website-Analyse: Messdienst abschalten (der Widerruf scheitert nie daran) + try { + const { endAnalyseForCustomer } = await import('../services/analyse.js') + await endAnalyseForCustomer(sub.customerId, { reason: 'widerruf_wechsel', mail: true, now: receivedAt }) + } catch (err) { + console.error(`[abo] Analyse nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message) + } + } const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen') return { sub, diff --git a/server/services/analyse.js b/server/services/analyse.js index 87fb713..915d141 100644 --- a/server/services/analyse.js +++ b/server/services/analyse.js @@ -23,7 +23,7 @@ export const MISSING_PRIVACY_MESSAGE = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' /** Austauschbar fuer Tests: Sammeldienst-Client, Neubau und Ticket. */ -export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments } +export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments, saveRemovalTicketId: (docId, ticketId) => updateDocument(config.collections.portalFeatures, docId, { removalTicketId: ticketId }) } function httpError(status, message) { const err = new Error(message) @@ -220,18 +220,65 @@ function publishTicket(customerId, project, siteKey, reason) { }) } -function removalTicket(customerId, project, siteKey) { +const removalTopic = (project) => `Website-Analyse von ${projectName(project)} entfernen` +/** Zeitpunkt dieser Abschaltung: kennzeichnet das Entfernen-Ticket (ein Ticket je Abschaltung). */ +const offMarker = (doc) => String(doc.offSince || doc.disabledAt || '') + +function removalTicket(customerId, project, siteKey, offSince) { const name = projectName(project) return analyseDeps.ticket({ customerId, project, featureKey: FEATURE_KEY, siteKey, - topic: `Website-Analyse von ${name} entfernen`, - details: removalTicketDetails({ name, domains: analyseHosts(project) }), + topic: removalTopic(project), + details: removalTicketDetails({ name, domains: analyseHosts(project), offSince }), }) } +/** Schon angelegtes Entfernen-Ticket dieser Abschaltung (Speichern der ID war gescheitert) oder null. Suchfehler werfen: dann lieber kein zweites Ticket. */ +async function findRemovalTicket(customerId, project, marker) { + if (!marker) return null + const tickets = await listDocuments(config.collections.workorders, [Query.equal('customerId', customerId), Query.limit(1000)]) + return tickets.find((t) => t.topic === removalTopic(project) && String(t.details || '').includes(`Abgeschaltet: ${marker}`)) || null +} + +/** + * Entfernen-Ticket fuer die aktuelle Abschaltung sicherstellen (idempotent): vorhandene ID gilt, sonst ein bereits + * angelegtes Ticket uebernehmen, sonst neu anlegen. Gibt das (ggf. aktualisierte) Dokument zurueck. Wirft bei Fehlern. + */ +async function ensureRemovalTicket(doc, project) { + if (doc.removalTicketId) return doc + const marker = offMarker(doc) + const found = await findRemovalTicket(doc.customerId, project, marker) + const ticketId = found ? found.$id : (await removalTicket(doc.customerId, project, doc.siteKey || '', marker)).ticketId + const saved = await analyseDeps.saveRemovalTicketId(doc.$id, ticketId) + return saved || { ...doc, removalTicketId: ticketId } +} + +async function projectOf(projectId) { + try { + return await getDocument(config.collections.websiteProjects, projectId) + } catch (err) { + if (err.status === 404) return null + throw err + } +} + +/** K18 mit beiden PDFs; sind sie nicht lesbar, geht nichts raus und der Marker bleibt leer (spaetere Wiederholung). */ +async function sendK18(doc, project) { + let attachments = null + try { + attachments = await analyseDeps.legalPdfs(doc.avvVersion || config.analyse.avvVersion) + } catch (err) { + console.error(`[analyse] K18-Anhänge für Projekt ${doc.projectId} nicht lesbar:`, err.message) + } + if (attachments && (await mailSafe(doc.customerId, 'K18', project, { ...k18Parts(doc), attachments }))) { + return updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() }) + } + return doc +} + export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date() }) { const viaCustomer = by === 'customer' let accepted @@ -339,22 +386,55 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons const { ticketId } = await publishTicket(customerId, project, doc.siteKey, rebuild.reason) doc = await updateDocument(config.collections.portalFeatures, doc.$id, { publishTicketId: ticketId }) } - if (needMail) { - // K18 mit beiden PDFs; sind sie nicht lesbar, geht nichts raus und der Marker bleibt leer (spaetere Wiederholung) - let attachments = null - try { - attachments = await analyseDeps.legalPdfs(doc.avvVersion || config.analyse.avvVersion) - } catch (err) { - console.error(`[analyse] K18-Anhänge für Projekt ${projectId} nicht lesbar:`, err.message) - } - if (attachments && (await mailSafe(customerId, 'K18', project, { ...k18Parts(doc), attachments }))) { - doc = await updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() }) - } - } + if (needMail) doc = await sendK18(doc, project) return { doc, changed: changed || needTicket || needMail } }) } +/** + * Gemeinsamer Kern aller Beendigungswege: Messdienst abschalten (stopSite), Dokument auf off + offSince, + * Historie, optional Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Fehler beim Messdienst werfen + * (Dokument bleibt dann unveraendert, der Abgleich holt nach); Historie, Neubau und Ticket nie. + */ +export async function endAnalyse({ doc, project, reason, now = new Date(), by = 'system', actor, tickets = true, rebuild = true }) { + if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey) + const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('off'), + offSince: now.toISOString(), + }) + try { + await analyseDeps.recordConsent({ + customerId: doc.customerId, + projectId: doc.projectId, + siteKey: doc.siteKey || '', + event: 'off', + avvVersion: config.analyse.avvVersion, + via: by === 'customer' || by === 'system' ? 'portal' : 'woms', + acceptedAt: now.toISOString(), + acceptedBy: (by === 'customer' && personLabel(actor)) || String(by).slice(0, 100), + textformDate: '', + textformChannel: '', + }) + } catch (err) { + // Ausschalten darf nie am Nachweis scheitern + console.warn(`[analyse] Historie (aus) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message) + } + console.log(`[analyse] Projekt ${doc.projectId} beendet (${reason || 'ausgeschaltet'})`) + let result = updated + if (project) { + if (rebuild) await rebuildSafe(project) + if (tickets && (await customerOf(doc.customerId).catch(() => null))) { + try { + result = await ensureRemovalTicket(updated, project) + } catch (err) { + // Ausschalten darf nie am Ticket scheitern; der taegliche Abgleich holt es nach + console.warn(`[analyse] Entfernen-Ticket für Projekt ${doc.projectId} fehlgeschlagen:`, err.message) + } + } + } + return { doc: result, changed: true } +} + export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer', actor }) { return withLocks([`analyse:${projectId}`], async () => { const project = await loadOwnProject(customerId, projectId) @@ -363,43 +443,32 @@ export async function deactivateAnalyse({ customerId, projectId, now = new Date( if (!existing || existing.state === 'off') return { doc: existing, changed: false } // Von uns gesperrt: der Kunde darf daran nichts aendern (nur Mitarbeiter) if (existing.state === 'disabled' && by === 'customer') return { doc: existing, changed: false } - if (existing.siteKey) { - await analyseDeps.client.putSite(existing.siteKey, { projectId, domains: analyseDomains(project), active: false }) - } - const doc = await updateDocument(config.collections.portalFeatures, existing.$id, { - ...stateFields('off'), - offSince: now.toISOString(), - }) - try { - await analyseDeps.recordConsent({ - customerId, - projectId, - siteKey: existing.siteKey || '', - event: 'off', - avvVersion: config.analyse.avvVersion, - via: by === 'customer' || by === 'system' ? 'portal' : 'woms', - acceptedAt: now.toISOString(), - acceptedBy: (by === 'customer' && personLabel(actor)) || String(by).slice(0, 100), - textformDate: '', - textformChannel: '', - }) - } catch (err) { - // Ausschalten darf nie am Nachweis scheitern - console.warn(`[analyse] Historie (aus) für Projekt ${projectId} fehlgeschlagen:`, err.message) - } - await rebuildSafe(project) - let result = doc - try { - const { ticketId } = await removalTicket(customerId, project, existing.siteKey || '') - result = await updateDocument(config.collections.portalFeatures, doc.$id, { removalTicketId: ticketId }) - } catch (err) { - // Ausschalten darf nie am Ticket scheitern - console.warn(`[analyse] Entfernen-Ticket für Projekt ${projectId} fehlgeschlagen:`, err.message) - } - return { doc: result, changed: true } + return endAnalyse({ doc: existing, project, reason: 'ausgeschaltet', now, by, actor }) }) } +const featureDocsOf = (extra = []) => listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', FEATURE_KEY), ...extra, Query.limit(1000)]) + +/** + * Alle aktiven Analysen eines Kunden beenden (Kunde geloescht, Widerruf Wechsel). Ein Fehler bei einem Dokument + * stoppt die uebrigen nicht; was bleibt, beendet der taegliche Abgleich. Bei mail:true bekommt der Kunde K19. + */ +export async function endAnalyseForCustomer(customerId, { reason, mail = false, now = new Date() } = {}) { + const gone = reason === 'kunde_geloescht' + const ended = [] + for (const doc of await featureDocsOf([Query.equal('customerId', customerId), Query.equal('state', 'active')])) { + try { + const project = await projectOf(doc.projectId).catch(() => null) + await withLocks([`analyse:${doc.projectId}`], () => endAnalyse({ doc, project, reason, now, by: 'system', tickets: !gone, rebuild: !gone })) + ended.push(doc.$id) + if (mail) await mailSafe(customerId, 'K19', project || {}) + } catch (err) { + console.error(`[analyse] Beenden (${reason}) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message) + } + } + return { ended } +} + /** Taeglich: aktive Analyse ohne laufendes Wartungs-Abo wird beendet, Kunde bekommt K19. */ export async function runAboCheck({ now = new Date() } = {}) { const docs = await listDocuments(config.collections.portalFeatures, [ @@ -423,3 +492,94 @@ export async function runAboCheck({ now = new Date() } = {}) { } return { checked: docs.length, deactivated } } + +const INELIGIBLE = (project) => (project.isPublic !== true ? 'nicht_oeffentlich' : !project.subdomain ? 'keine_subdomain' : null) + +/** Nach einer Aenderung am Projekt (Subdomain, Oeffentlichkeit): Berechtigung weg => beenden, sonst Domains nachziehen. */ +export async function analyseOnProjectChanged(projectId) { + const project = await projectOf(projectId) + const docs = await featureDocsOf([Query.equal('projectId', projectId), Query.equal('state', 'active')]) + for (const doc of docs) { + await withLocks([`analyse:${projectId}`], async () => { + if (!project) return endAnalyse({ doc, project: null, reason: 'projekt_geloescht', tickets: false, rebuild: false }) + const reason = INELIGIBLE(project) + if (reason) return endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich' }) + const domains = analyseDomains(project) + if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId, domains, active: true }) + }) + } +} + +/** + * Sperre/Loeschfrist (siteFeatures.disable): Messdienst per stopSite abschalten (auch ohne Projekt), offSince setzen, + * Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Wirft nie. + */ +export async function analyseDisabled(doc) { + try { + if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey) + } catch (err) { + console.warn(`[analyse] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message) + } + try { + let current = doc + if (!doc.offSince && doc.siteKey) { + current = await updateDocument(config.collections.portalFeatures, doc.$id, { offSince: doc.disabledAt || new Date().toISOString() }) + } + const project = await projectOf(doc.projectId).catch(() => null) + if (!project || !current.siteKey || !(await customerOf(doc.customerId).catch(() => null))) return + await rebuildSafe(project) + await ensureRemovalTicket(current, project) + } catch (err) { + console.warn(`[analyse] Nacharbeit nach Sperre für ${doc.projectId} fehlgeschlagen:`, err.message) + } +} + +/** + * Taeglicher Abgleich (nach runAboCheck), je Analyse-Dokument unabhaengig: + * - active: Projekt oder Kunde weg => beenden (ohne Ticket/Neubau); nicht mehr berechtigt => beenden; sonst Domains + * nachziehen (putSite) und K18 wiederholen, falls sie fehlt; + * - off/disabled: Messdienst abschalten (idempotent) und fehlendes Entfernen-Ticket nachholen (nur Abschaltungen ab der + * Fassung, also nach Livegang). + */ +export async function runAnalyseReconcile({ now = new Date() } = {}) { + const docs = await featureDocsOf() + const stats = { checked: docs.length, ended: [], stopped: [], tickets: [] } + for (const doc of docs) { + try { + if (doc.state === 'active') { + await withLocks([`analyse:${doc.projectId}`], async () => { + const project = await projectOf(doc.projectId) + if (!project) { + await endAnalyse({ doc, project: null, reason: 'projekt_geloescht', now, tickets: false, rebuild: false }) + return stats.ended.push(doc.$id) + } + if (!(await customerOf(doc.customerId))) { + await endAnalyse({ doc, project, reason: 'kunde_geloescht', now, tickets: false, rebuild: false }) + return stats.ended.push(doc.$id) + } + if (INELIGIBLE(project)) { + await endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich', now }) + return stats.ended.push(doc.$id) + } + const domains = analyseDomains(project) + if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains, active: true }) + if (!doc.activatedMailAt) await sendK18(doc, project) + }) + } else if ((doc.state === 'off' || doc.state === 'disabled') && SITE_KEY_RE.test(doc.siteKey || '')) { + await analyseDeps.client.stopSite(doc.siteKey) + stats.stopped.push(doc.$id) + const marker = offMarker(doc) + if (!doc.removalTicketId && marker >= config.analyse.avvVersion) { + const project = await projectOf(doc.projectId) + if (project && (await customerOf(doc.customerId))) { + await ensureRemovalTicket(doc, project) + stats.tickets.push(doc.$id) + } + } + } + } catch (err) { + console.error(`[analyse] Abgleich für Projekt ${doc.projectId} fehlgeschlagen:`, err.message) + } + } + return stats +} diff --git a/server/services/analyseTexts.js b/server/services/analyseTexts.js index 9b61477..def32e0 100644 --- a/server/services/analyseTexts.js +++ b/server/services/analyseTexts.js @@ -39,7 +39,7 @@ export function privacyTicketDetails({ name, domains, rebuildNote = '' }) { } /** Ticket beim Ausschalten (auch Abo-Ende): Quelle § 6 „Beim Ausschalten“. */ -export function removalTicketDetails({ name, domains }) { +export function removalTicketDetails({ name, domains, offSince = '' }) { return [ ...headLines({ name, domains }), `Zusatzseite: https://${lastDomain(domains)}/datenschutz-analyse.html`, @@ -47,6 +47,7 @@ export function removalTicketDetails({ name, domains }) { '1. Prüfen, ob die Website neu veröffentlicht wurde (Skript und Zusatzseite weg). Sonst neu veröffentlichen, spätestens innerhalb von fünf Werktagen.', '2. Hinweis auf die Reichweitenmessung aus der Datenschutzerklärung entfernen, wenn sie in unserem Repo liegt; sonst den Kunden im Chat bitten, ihn zu entfernen.', '3. Nicht auf das Stundenkontingent anrechnen.', + ...(offSince ? ['', `Abgeschaltet: ${offSince}`] : []), // Kennzeichen je Abschaltung (verhindert doppelte Tickets) ].join('\n') } diff --git a/server/services/analyticsClient.js b/server/services/analyticsClient.js index 9b97552..39856b7 100644 --- a/server/services/analyticsClient.js +++ b/server/services/analyticsClient.js @@ -47,6 +47,15 @@ export function createAnalyticsClient(opts = {}) { putSite(siteKey, { projectId, domains, active }) { return request('PUT', sitePath(siteKey), { projectId, domains, active }) }, + /** Beenden ohne Domainliste (active=0, offSince einmalig); unbekannter Schluessel zaehlt als erledigt. */ + async stopSite(siteKey) { + try { + return await request('POST', `${sitePath(siteKey)}/off`) + } catch (err) { + if (err.status === 404) return { ok: true, missing: true } + throw err + } + }, deleteSite(siteKey) { return request('DELETE', sitePath(siteKey)) }, diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index fec6823..2ff2053 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -274,6 +274,14 @@ export async function deleteCustomerWithPortalAccess(customerId) { throw error } + // Website-Analyse zuerst abschalten (kein Ticket, keine Mail); Fehler halten das Loeschen nicht auf, der taegliche Abgleich holt nach + try { + const { endAnalyseForCustomer } = await import('./analyse.js') + await endAnalyseForCustomer(customerId, { reason: 'kunde_geloescht', mail: false }) + } catch (err) { + console.error(`[customerAdmin] Analyse von Kunde ${customerId} nicht beendet:`, err.message) + } + const portalAccess = await getPortalAccessByCustomerId(customerId) if (portalAccess) { await deleteDocument( diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index a421568..878333b 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -517,6 +517,14 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain, console.error('[updateWebsiteProject] TLS-Sync fehlgeschlagen:', err.message) } + // Website-Analyse: Berechtigung/Domains neu pruefen (dynamischer Import wegen Zyklus); Fehler aendern das Ergebnis nicht + try { + const { analyseOnProjectChanged } = await import('./analyse.js') + await analyseOnProjectChanged(projectId) + } catch (err) { + console.error('[updateWebsiteProject] Analyse-Abgleich fehlgeschlagen:', err.message) + } + return { project: updated, deploy: deployResult, diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 4848610..1e37aff 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -302,13 +302,12 @@ export async function disable(docId, { by, adminNote } = {}) { return doc } -// Analyse gesperrt: Sammeldienst nimmt keine Daten mehr an. Best effort, Zustand ist schon gespeichert. +// Analyse gesperrt (auch Loeschfrist): Sammeldienst abschalten, Neubau und Entfernen-Ticket. Best effort, Zustand ist schon gespeichert. // Dynamischer Import: analyse.js importiert dieses Modul. async function stopAnalyseCollecting(doc) { try { - const { analyseDeps, analyseDomains } = await import('./analyse.js') - const project = await getDocument(config.collections.websiteProjects, doc.projectId) - await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains: analyseDomains(project), active: false }) + const { analyseDisabled } = await import('./analyse.js') + await analyseDisabled(doc) } catch (err) { console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message) } diff --git a/test/analyse.test.js b/test/analyse.test.js index 19f2173..e8ca922 100644 --- a/test/analyse.test.js +++ b/test/analyse.test.js @@ -24,7 +24,7 @@ const an = await import('../server/services/analyse.js') const sf = await import('../server/services/siteFeatures.js') const cl = await import('../server/services/analyseConsent.js') const priv = await import('../server/services/analysePrivacy.js') -const { updateDocument } = await import('../server/services/appwriteAdmin.js') +const { updateDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const mails = mailSink() @@ -35,8 +35,9 @@ after(() => { }) const puts = [] +const stops = [] const rebuilds = [] -an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }) } +an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }), stopSite: async (siteKey) => stops.push(siteKey) } an.analyseDeps.rebuild = async (project) => rebuilds.push(project.$id) const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion }) @@ -67,6 +68,7 @@ aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo beforeEach(() => { puts.length = 0 + stops.length = 0 rebuilds.length = 0 mails.sent.length = 0 }) @@ -156,8 +158,8 @@ test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts' assert.equal(d.state, 'off') assert.equal(d.enabled, false) assert.equal(d.offSince, '2026-10-05T10:00:00.000Z') - assert.equal(puts.length, 1) - assert.equal(puts[0].active, false) + assert.equal(puts.length, 0) + assert.deepEqual(stops, [d.siteKey]) assert.deepEqual(rebuilds, ['p1']) assert.equal((await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p1', now })).changed, false) await assert.rejects(an.deactivateAnalyse({ customerId: 'c2', projectId: 'p1' }), { status: 403 }) @@ -466,7 +468,7 @@ test('Entfernen-Ticket: Kunde, Mitarbeiter, zweimal = ein Ticket, Aus/An/Aus = z assert.match(ticket.details, /fünf Werktagen/) assert.match(ticket.details, /Stundenkontingent/) await an.activateAnalyse({ customerId: 'c1', projectId: 'p22', ...staff() }) - const off2 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: NOW, by: 'Kenso' }) + const off2 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: new Date(NOW.getTime() + 3600000), by: 'Kenso' }) assert.equal(removal().length, 2) assert.notEqual(off2.doc.removalTicketId, off1.doc.removalTicketId) }) @@ -524,3 +526,180 @@ test('rebuildSafe-Ergebnis: kein_repo, preview_aus, fehler => Veröffentlichen-T } an.analyseDeps.rebuild = original }) + +// ---- Task 9: alle Beendigungswege ---- +const FULL = { avvVersion: '2026-10-02', avvAcceptedAt: '2026-10-03T08:00:00.000Z', avvAcceptedBy: 'x', activatedAt: '2026-10-03T08:00:00.000Z', activatedMailAt: '2026-10-03T08:00:01.000Z', privacyTicketId: 'pt' } +async function seedFeature(customerId, projectId, state, extra = {}) { + const d = await sf.ensureFeatureDoc(customerId, projectId, 'analyse') + return updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields(state), accentColor: '#111111', ...FULL, ...extra }) +} +const removalTopics = (name) => topics().filter((t) => t === `Website-Analyse von ${name} entfernen`) + +test('Kunde gelöscht: endAnalyseForCustomer stoppt jeden aktiven Schlüssel, off, kein Ticket, keine Mail, Fehler werden geschluckt', async () => { + aw.seed('customers', { $id: 'k1', name: 'Weg Eins', email: 'weg1@example.invalid' }) + aw.seed('websiteProjects', { $id: 'k1p1', customerId: 'k1', projectName: 'KP1', subdomain: 'kp1', isPublic: true, repoFullName: 'WEBklar/kp1' }) + aw.seed('websiteProjects', { $id: 'k1p2', customerId: 'k1', projectName: 'KP2', subdomain: 'kp2', isPublic: true }) + const a = await seedFeature('k1', 'k1p1', 'active') + const b = await seedFeature('k1', 'k1p2', 'active') + const real = an.analyseDeps.client.stopSite + an.analyseDeps.client.stopSite = async (k) => { stops.push(k); if (k === b.siteKey) throw new Error('Sammeldienst weg') } + const r = await an.endAnalyseForCustomer('k1', { reason: 'kunde_geloescht', mail: false }) + an.analyseDeps.client.stopSite = real + assert.deepEqual(stops.sort(), [a.siteKey, b.siteKey].sort()) + assert.equal((await doc('k1', 'k1p1')).state, 'off') + assert.ok((await doc('k1', 'k1p1')).offSince) + assert.equal((await doc('k1', 'k1p2')).state, 'active', 'Fehler bei einem Dokument: Abgleich holt nach') + assert.deepEqual(r.ended, [a.$id]) + assert.equal(removalTopics('KP1').length, 0) + assert.equal(rebuilds.length, 0) + assert.equal(mails.sent.length, 0) +}) + +test('Abgleich: Projekt gelöscht, Kunde gelöscht => stopSite, off, offSince, kein Ticket', async () => { + aw.seed('customers', { $id: 'k2', name: 'Weg Zwei', email: 'weg2@example.invalid' }) + aw.seed('websiteProjects', { $id: 'k2p', customerId: 'k2', projectName: 'KP3', subdomain: 'kp3', isPublic: true }) + aw.seed('websiteProjects', { $id: 'k3p', customerId: 'kgibtsnicht', projectName: 'KP4', subdomain: 'kp4', isPublic: true }) + const gone = await seedFeature('k2', 'k2p', 'active') + const orphan = await seedFeature('kgibtsnicht', 'k3p', 'active') + await deleteDocument(config.collections.websiteProjects, 'k2p') + const now = new Date('2026-11-02T03:00:00.000Z') + await an.runAnalyseReconcile({ now }) + assert.ok(stops.includes(gone.siteKey) && stops.includes(orphan.siteKey)) + for (const [c, p] of [['k2', 'k2p'], ['kgibtsnicht', 'k3p']]) { + const d = await doc(c, p) + assert.equal(d.state, 'off') + assert.equal(d.offSince, now.toISOString()) + } + assert.equal(removalTopics('KP3').length + removalTopics('KP4').length, 0) + assert.equal(mails.sent.length, 0) +}) + +test('Abgleich: geänderte liveDomain => putSite mit neuer Domainliste; off-Dokument => stopSite; K18-Wiederholung genau einmal', async () => { + aw.seed('websiteProjects', { $id: 'k4p', customerId: 'c1', projectName: 'KP5', subdomain: 'kp5', liveDomain: 'neu-domain.de', isPublic: true }) + const live = await seedFeature('c1', 'k4p', 'active', { activatedMailAt: '' }) + aw.seed('websiteProjects', { $id: 'k5p', customerId: 'c1', projectName: 'KP6', subdomain: 'kp6', isPublic: true }) + const off = await seedFeature('c1', 'k5p', 'off', { offSince: '2026-10-03T09:00:00.000Z', removalTicketId: 'vorhanden' }) + await an.runAnalyseReconcile({ now: NOW }) + assert.deepEqual(puts.filter((p) => p.siteKey === live.siteKey), [{ siteKey: live.siteKey, projectId: 'k4p', domains: ['kp5.project.webklar.com', 'neu-domain.de', 'www.neu-domain.de'], active: true }]) + assert.ok(stops.includes(off.siteKey)) + assert.equal(mails.sent.length, 1) + assert.ok((await doc('c1', 'k4p')).activatedMailAt) + await an.runAnalyseReconcile({ now: NOW }) + assert.equal(mails.sent.length, 1, 'Marker gesetzt: keine zweite K18') +}) + +test('Domain/Öffentlichkeit: analyseOnProjectChanged beendet bei nicht öffentlich (Ticket), putSite bei neuer Subdomain', async () => { + aw.seed('websiteProjects', { $id: 'k6p', customerId: 'c1', projectName: 'KP7', subdomain: 'kp7', isPublic: true, repoFullName: 'WEBklar/kp7' }) + const d = await seedFeature('c1', 'k6p', 'active') + await updateDocument(config.collections.websiteProjects, 'k6p', { subdomain: 'kp7neu' }) + await an.analyseOnProjectChanged('k6p') + assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'k6p', domains: ['kp7neu.project.webklar.com'], active: true }]) + assert.equal(stops.length, 0) + puts.length = 0 + await updateDocument(config.collections.websiteProjects, 'k6p', { isPublic: false }) + await an.analyseOnProjectChanged('k6p') + assert.deepEqual(stops, [d.siteKey]) + const after = await doc('c1', 'k6p') + assert.equal(after.state, 'off') + assert.ok(after.offSince) + assert.ok(after.removalTicketId) + assert.equal(removalTopics('KP7').length, 1) + assert.deepEqual(rebuilds, ['k6p']) + await an.analyseOnProjectChanged('k6p') // kein aktives Dokument mehr + assert.equal(removalTopics('KP7').length, 1) + await an.analyseOnProjectChanged('gibts-nicht') +}) + +test('Löschfrist/Sperre: siteFeatures.disable beendet Sammeldienst, baut neu und legt Entfernen-Ticket genau einmal an', async () => { + aw.seed('websiteProjects', { $id: 'k7p', customerId: 'c1', projectName: 'KP8', subdomain: 'kp8', isPublic: true, repoFullName: 'WEBklar/kp8' }) + const d = await seedFeature('c1', 'k7p', 'active') + const r = await sf.disable(d.$id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' }) + assert.equal(r.state, 'disabled') + assert.deepEqual(stops, [d.siteKey]) + assert.equal(puts.length, 0) + const after = await doc('c1', 'k7p') + assert.ok(after.offSince) + assert.ok(after.removalTicketId) + assert.deepEqual(rebuilds, ['k7p']) + assert.equal(removalTopics('KP8').length, 1) + await an.runAnalyseReconcile({ now: NOW }) + assert.equal(removalTopics('KP8').length, 1) + assert.equal((await doc('c1', 'k7p')).state, 'disabled') +}) + +test('Löschfrist ohne Projekt: stopSite, kein Ticket, kein Neubau, kein Fehler', async () => { + aw.seed('websiteProjects', { $id: 'k8p', customerId: 'c1', projectName: 'KP9', subdomain: 'kp9', isPublic: true }) + const d = await seedFeature('c1', 'k8p', 'active') + await deleteDocument(config.collections.websiteProjects, 'k8p') + await sf.disable(d.$id, { adminNote: 'x' }) + assert.deepEqual(stops, [d.siteKey]) + assert.equal(removalTopics('KP9').length, 0) + assert.equal(rebuilds.length, 0) +}) + +test('Abgleich holt fehlendes Entfernen-Ticket nach (off/disabled), idempotent; Altfälle vor der Fassung nicht', async () => { + aw.seed('websiteProjects', { $id: 'k9p', customerId: 'c1', projectName: 'KP10', subdomain: 'kp10', isPublic: true }) + aw.seed('websiteProjects', { $id: 'k10p', customerId: 'c1', projectName: 'KP11', subdomain: 'kp11', isPublic: true }) + aw.seed('websiteProjects', { $id: 'k11p', customerId: 'c1', projectName: 'KP12', subdomain: 'kp12', isPublic: true }) + await seedFeature('c1', 'k9p', 'off', { offSince: '2026-10-04T09:00:00.000Z' }) + await seedFeature('c1', 'k10p', 'disabled', { offSince: '', disabledAt: '2026-10-04T09:00:00.000Z' }) + await seedFeature('c1', 'k11p', 'off', { offSince: '2026-09-20T09:00:00.000Z' }) + await an.runAnalyseReconcile({ now: NOW }) + await an.runAnalyseReconcile({ now: NOW }) + assert.equal(removalTopics('KP10').length, 1) + assert.equal(removalTopics('KP11').length, 1) + assert.equal(removalTopics('KP12').length, 0, 'vor Livegang beendet') + assert.ok((await doc('c1', 'k9p')).removalTicketId) +}) + +test('Ticket angelegt, Speichern der ID scheiterte: Abgleich übernimmt das vorhandene Ticket (kein zweites)', async () => { + aw.seed('websiteProjects', { $id: 'k12p', customerId: 'c1', projectName: 'KP13', subdomain: 'kp13', isPublic: true }) + const off = '2026-10-04T09:00:00.000Z' + await seedFeature('c1', 'k12p', 'off', { offSince: off }) + // Zustand nach einem Fehler beim Speichern: Ticket fuer genau diese Abschaltung existiert, removalTicketId fehlt + const orig = an.analyseDeps.ticket + let created = null + an.analyseDeps.ticket = async (a) => (created = await orig(a)) + const realUpdate = an.analyseDeps.saveRemovalTicketId + an.analyseDeps.saveRemovalTicketId = async () => { throw new Error('Appwrite weg') } + await an.runAnalyseReconcile({ now: NOW }) + an.analyseDeps.saveRemovalTicketId = realUpdate + an.analyseDeps.ticket = orig + assert.ok(created, 'Ticket entstand') + assert.ok(!(await doc('c1', 'k12p')).removalTicketId, 'ID nicht gespeichert') + await an.runAnalyseReconcile({ now: NOW }) + assert.equal(removalTopics('KP13').length, 1) + assert.equal((await doc('c1', 'k12p')).removalTicketId, created.ticketId) +}) + +test('Aus/An/Aus: zweite Abschaltung bekommt trotz offenem ersten Ticket ein eigenes (Marker je Abschaltung)', async () => { + const tickets = [...aw.docs('workorders').values()].filter((t) => t.topic === 'Website-Analyse von Bäckerei Aus entfernen') + assert.equal(tickets.length, 2) + assert.notEqual(tickets[0].details, tickets[1].details) +}) + +test('deleteCustomerWithPortalAccess: Analyse aus (kein Ticket, keine Mail), Löschen gelingt auch wenn stopSite wirft', async () => { + const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') + aw.seed('customers', { $id: 'kd1', name: 'Löschen Eins', email: 'loe1@example.invalid' }) + aw.seed('customers', { $id: 'kd2', name: 'Löschen Zwei', email: 'loe2@example.invalid' }) + aw.seed('websiteProjects', { $id: 'kd1p', customerId: 'kd1', projectName: 'KD1', subdomain: 'kd1', isPublic: true, repoFullName: 'WEBklar/kd1' }) + aw.seed('websiteProjects', { $id: 'kd2p', customerId: 'kd2', projectName: 'KD2', subdomain: 'kd2', isPublic: true }) + const a = await seedFeature('kd1', 'kd1p', 'active') + const b = await seedFeature('kd2', 'kd2p', 'active') + await deleteCustomerWithPortalAccess('kd1') + assert.deepEqual(stops, [a.siteKey]) + assert.equal((await doc('kd1', 'kd1p')).state, 'off') + assert.equal(removalTopics('KD1').length, 0) + assert.equal(mails.sent.length, 0) + assert.equal(rebuilds.length, 0) + assert.equal(aw.docs('customers').has('kd1'), false) + const real = an.analyseDeps.client.stopSite + an.analyseDeps.client.stopSite = async () => { throw new Error('weg') } + await deleteCustomerWithPortalAccess('kd2') + an.analyseDeps.client.stopSite = real + assert.equal(aw.docs('customers').has('kd2'), false) + assert.equal((await doc('kd2', 'kd2p')).state, 'active', 'bleibt für den Abgleich (Kunde fehlt)') + await an.runAnalyseReconcile({ now: NOW }) + assert.equal((await doc('kd2', 'kd2p')).state, 'off') + assert.ok(stops.includes(b.siteKey)) +}) diff --git a/test/analyseRoutes.test.js b/test/analyseRoutes.test.js index 59e4f7a..88fee85 100644 --- a/test/analyseRoutes.test.js +++ b/test/analyseRoutes.test.js @@ -33,11 +33,13 @@ after(() => { }) const puts = [] +const stops = [] const statsCalls = [] let statsFail = false const realClient = createAnalyticsClient({ heatSecret: 'h'.repeat(32), adminToken: 't' }) an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }), + stopSite: async (siteKey) => stops.push(siteKey), getStats: async (siteKey, range) => { statsCalls.push({ siteKey, range }) if (statsFail) throw Object.assign(new Error('x'), { status: 502 }) @@ -71,6 +73,7 @@ const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse') beforeEach(() => { puts.length = 0 + stops.length = 0 statsCalls.length = 0 statsFail = false config.analyse.enabled = true @@ -132,7 +135,7 @@ test('Aktivieren und Deaktivieren (Kunde)', async () => { assert.equal(view.accentColor, '#112233') const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }) assert.deepEqual(off.body, { ok: true, state: 'off' }) - assert.equal(puts[1].active, false) + assert.equal(stops.length, 1) const notElig = await post(`${A}/activate`, { projectId: 'p2', consent: consent() }, { cookie }) assert.equal(notElig.status, 409) }) @@ -270,18 +273,19 @@ test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async assert.equal(staff.doc.state, 'off') }) -test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst active:false', async () => { +test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst per stopSite', async () => { await adminPost('p6', { on: true, ...tf() }) const cur = await featureDoc('c1', 'p6') puts.length = 0 + stops.length = 0 await sf.disable(cur.$id, { by: 'Test' }) - assert.equal(puts.length, 1) - assert.deepEqual(puts[0], { siteKey: cur.siteKey, projectId: 'p6', domains: ['neu.project.webklar.com'], active: false }) + assert.deepEqual(stops, [cur.siteKey]) + assert.equal(puts.length, 0) // Ausfall des Sammeldienstes verhindert das Abschalten nicht - const real = an.analyseDeps.client.putSite - an.analyseDeps.client.putSite = async () => { throw new Error('weg') } + const real = an.analyseDeps.client.stopSite + an.analyseDeps.client.stopSite = async () => { throw new Error('weg') } const again = await sf.disable(cur.$id, { by: 'Test' }) - an.analyseDeps.client.putSite = real + an.analyseDeps.client.stopSite = real assert.equal(again.state, 'disabled') }) diff --git a/test/analyticsClient.test.js b/test/analyticsClient.test.js index 20042d6..2b91031 100644 --- a/test/analyticsClient.test.js +++ b/test/analyticsClient.test.js @@ -92,3 +92,15 @@ test('heatToken: von der Analytics-Verifikation akzeptiert, 30 Min gueltig', () test('heatToken ohne heatSecret => 503', () => { assert.throws(() => mk({ heatSecret: '' }).heatToken({ siteKey: 'abcdefghij123456', path: '/', vp: 'd' }), (e) => e.status === 503) }) + +test('stopSite: POST /off mit Bearer, 404 zaehlt als erledigt, andere Fehler werfen', async () => { + reset() + await mk().stopSite('abcdefghij123456') + assert.equal(calls[0].method, 'POST') + assert.equal(calls[0].url, '/a/admin/sites/abcdefghij123456/off') + assert.equal(calls[0].auth, 'Bearer tok-secret') + reset(() => ({ status: 404, body: { error: 'x' } })) + assert.deepEqual(await mk().stopSite('abcdefghij123456'), { ok: true, missing: true }) + reset(() => ({ status: 500, body: {} })) + await assert.rejects(mk().stopSite('abcdefghij123456'), (e) => e.status === 500) +}) diff --git a/test/analyticsContract.test.js b/test/analyticsContract.test.js index ed8d052..9d11b23 100644 --- a/test/analyticsContract.test.js +++ b/test/analyticsContract.test.js @@ -60,3 +60,15 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as await assert.rejects(client.getStats(SITE, '7'), (e) => e.status === 404) assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events_raw').get().n, 0) }) + +test('Vertrag: stopSite setzt active=0 und offSince, unbekannt zaehlt als erledigt', async () => { + const KEY2 = 'fedcba9876543210fedc' + await client.putSite(KEY2, { projectId: 'p2', domains: [DOMAIN], active: true }) + await client.stopSite(KEY2) + const row = db.prepare('SELECT active, offSince FROM sites WHERE siteKey = ?').get(KEY2) + assert.equal(row.active, 0) + assert.ok(row.offSince) + await client.stopSite(KEY2) // zweiter Aufruf aendert nichts + assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince) + assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true }) +}) diff --git a/test/giteaAdmin.test.js b/test/giteaAdmin.test.js index a5dea40..f1c3eca 100644 --- a/test/giteaAdmin.test.js +++ b/test/giteaAdmin.test.js @@ -30,6 +30,10 @@ before(async () => { { repoFullName: 'knso/alt', projectName: 'alt', subdomain: 'alt' }, { repoFullName: 'knso/synced-sub', projectName: 'synced-sub', status: 'synced', subdomain: 'synced-sub' }, { repoFullName: 'knso/lead', projectName: 'lead', status: 'deployed', subdomain: 'lead' }, + { $id: 'wp_an', repoFullName: 'knso/alt', projectName: 'Analyse-Projekt', subdomain: 'analyseprojekt', isPublic: true, projectType: 'website' }, + ], + portalFeatures: [ + { $id: 'wp_an_feat', customerId: 'cx', projectId: 'wp_an', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wpansitekey01234567' }, ], }, }) @@ -111,3 +115,30 @@ test('Repo-Liste hat eine Obergrenze', async () => { assert.equal(repos.length, 2000) assert.equal(capGitea.calls.filter((c) => c.path === '/api/v1/repos/search').length, 40) }) + +test('updateWebsiteProject: neue Subdomain => putSite mit neuer Domain; nicht öffentlich => stopSite; Hook-Fehler ändert das Ergebnis nicht', async () => { + const { updateWebsiteProject } = await import('../server/services/giteaAdmin.js') + const { analyseDeps } = await import('../server/services/analyse.js') + const calls = [] + const real = analyseDeps.client + analyseDeps.client = { + putSite: async (k, b) => calls.push(['put', k, b]), + stopSite: async (k) => calls.push(['stop', k]), + } + try { + const r1 = await updateWebsiteProject('wp_an', { subdomain: 'analyseneu', projectType: 'website' }) + assert.equal(r1.subdomain, 'analyseneu') + assert.deepEqual(calls, [['put', 'wpansitekey01234567', { projectId: 'wp_an', domains: ['analyseneu.project.webklar.com'], active: true }]]) + calls.length = 0 + const r2 = await updateWebsiteProject('wp_an', { projectType: 'preview' }) + assert.equal(r2.isPublic, false) + assert.deepEqual(calls, [['stop', 'wpansitekey01234567']]) + const { updateDocument } = await import('../server/services/appwriteAdmin.js') + await updateDocument(config.collections.portalFeatures, 'wp_an_feat', { state: 'active', enabled: true }) + analyseDeps.client = { putSite: async () => { throw new Error('weg') }, stopSite: async () => { throw new Error('weg') } } + const r3 = await updateWebsiteProject('wp_an', { subdomain: 'nochneu', projectType: 'website' }) + assert.equal(r3.subdomain, 'nochneu') + } finally { + analyseDeps.client = real + } +}) diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 343394a..89e6ee3 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -15,6 +15,7 @@ const aw = await startAppwriteMock({ subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), hourCredits: schemaKeys('hourCredits'), + portalFeatures: [...schemaKeys('portalFeatures'), 'siteKey', 'offSince', 'activatedAt', 'accentColor', 'removalTicketId'], }, }) const st = await startFakeStripe() @@ -28,6 +29,7 @@ const { withdrawalDeadline } = await import('../server/services/deadlines.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { analyseDeps } = await import('../server/services/analyse.js') const app = await startTestApp({ '/api/abo': aboRoutes }) // Mails landen in der Senke statt bei SMTP (F25): K03/K04 an den Kunden, T08/T09 ans Team sind pruefbar const mails = mailSink() @@ -396,6 +398,61 @@ test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Pre assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/) }) +// Analyse hat nur mit Wartungs-Abo Bestand: der Widerruf des Wechsels beendet sie (Messdienst aus, K19) +async function withActiveAnalyse(fn) { + const stops = [] + const real = analyseDeps.client + analyseDeps.client = { stopSite: async (k) => { stops.push(k) } } + aw.seed('portalFeatures', { $id: 'wf_feat', customerId: 'c1', projectId: 'wf_p', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wfsitekey0123456789' }) + try { + return await fn(stops) + } finally { + analyseDeps.client = real + } +} +const wechselIn = async (days) => { + const consentAt = daysAgo(days) + await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', + details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt }, + consent: { ...consent, consentAt }, status: 'erledigt', + }) +} + +test('Widerruf Wechsel in der Frist beendet die Analyse des Kunden (stopSite, off, K19); Fehler lässt den Widerruf auf 200', async () => { + await resetSubH({ units: 0 }) + await wechselIn(3) + await withActiveAnalyse(async (stops) => { + const res = await withdraw({ order: 'wechsel' }) + assert.equal(res.status, 200) + assert.deepEqual(stops, ['wfsitekey0123456789']) + const d = aw.docs('portalFeatures').get('wf_feat') + assert.equal(d.state, 'off') + assert.ok(d.offSince) + assert.ok(bySubject('Deine Website-Analyse wurde beendet')) + }) + // wirft der Messdienst, bleibt der Widerruf 200 + await resetSubH({ units: 0 }) + await wechselIn(3) + await withActiveAnalyse(async () => { + analyseDeps.client = { stopSite: async () => { throw new Error('Messdienst weg') } } + const res = await withdraw({ order: 'wechsel' }) + assert.equal(res.status, 200) + assert.equal((await events('wechsel'))[0].status, 'widerrufen') + }) +}) + +test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse nicht', async () => { + await resetSubH({ units: 0 }) + await wechselIn(30) + await withActiveAnalyse(async (stops) => { + assert.equal((await withdraw({ order: 'wechsel' })).status, 200) + assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200) + assert.deepEqual(stops, []) + assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active') + }) +}) + test('Widerruf nach Fristende: 200, nichts geändert', async () => { await resetSubH({ units: 0 }) const consentAt = daysAgo(30) diff --git a/test/retention.test.js b/test/retention.test.js index 5263630..92deac0 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -315,13 +315,13 @@ test('retention: Analyse-Dokument schaltet auch den Sammeldienst ab', async () = aw.seed('portalFeatures', { $id: 'ra_feat', customerId: 'ra', projectId: 'rap', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghij234567' }) const puts = [] const ok = analyseDeps.client - analyseDeps.client = { putSite: async (k, b) => { puts.push([k, b]) } } + analyseDeps.client = { putSite: async (k, b) => { puts.push(['put', k, b]) }, stopSite: async (k) => { puts.push(['stop', k]) } } try { const plan = await findDueDeletions(JAN_2026) assert.ok(plan.news.find((n) => n.customerId === 'ra').featureIds.includes('ra_feat')) const result = await applyDeletions(plan) assert.deepEqual(result.failed, []) - assert.deepEqual(puts, [['abcdefghij234567', { projectId: 'rap', domains: ['altprojekt.project.webklar.com'], active: false }]]) + assert.deepEqual(puts, [['stop', 'abcdefghij234567']]) assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'ra_feat').state, 'disabled') } finally { analyseDeps.client = ok