mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
fix(admin): wipe-all-data clears notification channels, push subs, telegram deletions
The "Wipe all data" admin transaction was scoped only to measurements, medications, intakes, API tokens, Withings connections, and auth challenges. Notification channels (encrypted Telegram bot tokens in NotificationChannel.config), Web Push subscriptions, and queued Telegram message deletions survived the wipe — a scope leak first flagged in the v1.4.6 multi-agent QA pass. Extends the transaction with `notificationChannel.deleteMany`, `pushSubscription.deleteMany`, and `telegramScheduledDeletion.deleteMany`. NotificationPreference rows cascade via the schema FK. Feedback rows are intentionally preserved (T8 v1.4.6) and the i18n copy now states this explicitly so admins know bug reports survive a wipe. Counts for the three new tables are returned to the UI. Adds an integration regression test that seeds all three tables, invokes the route handler with a real admin session, and asserts the wipe clears them while Feedback + AuditLog survive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0668d35704
commit
512a6a619c
5 files changed
+177
-4
No files matched your search
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<string, string>();
|
||||
const headerJar = new Map<string, string>();
|
||||
|
||||
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<typeof DELETE>[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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user