diff --git a/messages/de.json b/messages/de.json index 3e8768f69..a36eb32fe 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1377,10 +1377,10 @@ "deleteAllData": "Alle Daten global löschen", "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Benutzer. Benutzerkonten bleiben bestehen.", "deleteAllConfirm": "Alle Daten wirklich löschen?", - "deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen und offene Authentifizierungs-Challenges aller Benutzer unwiderruflich. Benutzerkonten und das Audit-Log bleiben erhalten, damit der Löschvorgang selbst nachvollziehbar bleibt.", + "deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Benutzer unwiderruflich. Benutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.", "deleteButton": "Alle Daten löschen", "finalDelete": "Endgültig löschen", - "deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges", + "deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges, {notificationChannels} Benachrichtigungskanäle, {pushSubscriptions} Push-Abonnements, {telegramScheduledDeletions} eingeplante Telegram-Löschungen. Feedback bleibt erhalten.", "authRegister": "Registrierung", "authLogin": "Anmeldung", "authLoginPasskey": "Passkey-Anmeldung", diff --git a/messages/en.json b/messages/en.json index f93278d39..4a7ad44e0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1377,10 +1377,10 @@ "deleteAllData": "Delete all data globally", "deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users. User accounts will be preserved.", "deleteAllConfirm": "Really delete all data?", - "deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, and any open authentication challenges for all users. User accounts and the audit log are preserved so the wipe itself remains traceable.", + "deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, open authentication challenges, notification channels (Telegram, ntfy, Web Push), push subscriptions, and queued Telegram message deletions for all users. User accounts, the audit log, and submitted feedback are preserved so the wipe itself remains traceable and bug reports stay triageable.", "deleteButton": "Delete all data", "finalDelete": "Delete permanently", - "deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges", + "deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", "authRegister": "Registration", "authLogin": "Login", "authLoginPasskey": "Passkey login", diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts index a8c160ca0..c24736982 100644 --- a/src/app/api/admin/data/route.ts +++ b/src/app/api/admin/data/route.ts @@ -56,6 +56,15 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const withingsConnections = await tx.withingsConnection.deleteMany({}); const authChallenges = await tx.authChallenge.deleteMany({}); + // v1.4.6 left these out, so encrypted Telegram bot tokens + // (NotificationChannel.config) and Web Push endpoints survived a + // wipe. Cascading via NotificationPreference is handled by the + // schema's onDelete: Cascade on the channel FK. + const notificationChannels = await tx.notificationChannel.deleteMany({}); + const pushSubscriptions = await tx.pushSubscription.deleteMany({}); + const telegramScheduledDeletions = + await tx.telegramScheduledDeletion.deleteMany({}); + await tx.user.updateMany({ data: { heightCm: null, @@ -80,6 +89,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { apiTokens: apiTokens.count, withingsConnections: withingsConnections.count, authChallenges: authChallenges.count, + notificationChannels: notificationChannels.count, + pushSubscriptions: pushSubscriptions.count, + telegramScheduledDeletions: telegramScheduledDeletions.count, }; }); diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index 8aef558c3..3476476ce 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -38,6 +38,9 @@ export function DangerZoneSection({ id }: { id: string }) { apiTokens: number; withingsConnections: number; authChallenges: number; + notificationChannels: number; + pushSubscriptions: number; + telegramScheduledDeletions: number; }; }, onSuccess: (data) => { @@ -50,6 +53,9 @@ export function DangerZoneSection({ id }: { id: string }) { apiTokens: data.apiTokens, withingsConnections: data.withingsConnections, authChallenges: data.authChallenges, + notificationChannels: data.notificationChannels, + pushSubscriptions: data.pushSubscriptions, + telegramScheduledDeletions: data.telegramScheduledDeletions, }), ); }, diff --git a/tests/integration/admin-data-wipe.test.ts b/tests/integration/admin-data-wipe.test.ts new file mode 100644 index 000000000..6ce84e255 --- /dev/null +++ b/tests/integration/admin-data-wipe.test.ts @@ -0,0 +1,155 @@ +/** + * Integration regression guard for `DELETE /api/admin/data`. + * + * The admin "wipe all data" path nulls personal columns on `User` and + * deletes a curated list of personal-data tables in one transaction. + * v1.4.6 left three notification-channel-style tables out of that + * list, leaving encrypted Telegram bot tokens (NotificationChannel.config) + * and Web Push endpoints behind. This test is the regression guard + * that the wipe now extends to: + * + * - notification_channels (Telegram/ntfy/web-push channel config) + * - push_subscriptions (raw browser push endpoints) + * - telegram_scheduled_deletions (queued message-delete tickets) + * + * Feedback rows are intentionally PRESERVED (T8 from v1.4.6 + admin + * i18n copy now states this explicitly) — a wipe must not erase + * user-submitted bug reports admins still need to triage. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +// next/headers cookie + ip headers stub for getSession() inside +// requireAdmin(). The admin route reads x-forwarded-for too; we stub +// both to keep the handler reachable. +const cookieJar = new Map(); +const headerJar = new Map(); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("DELETE /api/admin/data wipe scope", () => { + it("clears notification channels, push subscriptions, telegram deletions; preserves Feedback + AuditLog", async () => { + const prisma = getPrismaClient(); + + // ── arrange ── + const admin = await prisma.user.create({ + data: { + username: "wipe-admin", + email: "wipe-admin@example.test", + role: "ADMIN", + }, + }); + + const session = await prisma.session.create({ + data: { + userId: admin.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + + // Seed the channels & adjacent rows the wipe must clear. + await prisma.notificationChannel.create({ + data: { + userId: admin.id, + type: "TELEGRAM", + enabled: true, + // Pretend-encrypted blob — content is irrelevant, presence is. + config: "encrypted:test", + }, + }); + await prisma.pushSubscription.create({ + data: { + userId: admin.id, + endpoint: "https://push.example/sub-" + admin.id, + p256dh: "p256dh-enc", + auth: "auth-enc", + }, + }); + await prisma.telegramScheduledDeletion.create({ + data: { + userId: admin.id, + chatId: "1234", + messageId: 42, + deleteAfter: new Date(Date.now() + 60_000), + }, + }); + + // Seed a feedback row that MUST survive the wipe (per i18n copy). + const feedbackRow = await prisma.feedback.create({ + data: { + userId: admin.id, + category: "BUG", + subject: "survives wipe", + description: "feedback rows are preserved", + }, + }); + + // ── act: invoke the route handler exactly as Next.js would ── + const { DELETE } = await import("@/app/api/admin/data/route"); + const req = new Request("http://localhost/api/admin/data", { + method: "DELETE", + headers: { + "content-type": "application/json", + "x-forwarded-for": "127.0.0.1", + }, + body: JSON.stringify({ confirm: "DELETE ALL" }), + }); + const res = await DELETE(req as unknown as Parameters[0]); + expect(res.status).toBe(200); + + // ── assert: notification-related tables are empty ── + expect( + await prisma.notificationChannel.count({ where: { userId: admin.id } }), + ).toBe(0); + expect( + await prisma.pushSubscription.count({ where: { userId: admin.id } }), + ).toBe(0); + expect( + await prisma.telegramScheduledDeletion.count({ + where: { userId: admin.id }, + }), + ).toBe(0); + + // ── assert: feedback row survives, AuditLog rows survive ── + const feedbackAfter = await prisma.feedback.findUnique({ + where: { id: feedbackRow.id }, + }); + expect(feedbackAfter).not.toBeNull(); + expect(feedbackAfter?.subject).toBe("survives wipe"); + + // The wipe writes admin.data.clear.start AND admin.data.clear audit + // entries. They must outlive the operation (audit log is immune). + const auditCount = await prisma.auditLog.count({ + where: { userId: admin.id }, + }); + expect(auditCount).toBeGreaterThanOrEqual(2); + }); +});