From 31959e436e7b983e2f40adf3a088e205fc3f984e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 01:31:11 +0200 Subject: [PATCH] fix(bugreport): admin toggle now actually blocks feedback submissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Bug reports" admin toggle had no effect. The form on `/bugreport` posts to `/api/feedback`, which was the wrong endpoint for the gate — it lived only on the legacy `/api/bugreport` route the form does not call. Users still submitted into the local feedback inbox after the admin disabled the feature, and the prior commit message claimed "the report button gracefully disappears" was untrue. - `/api/bugreport/status` now also returns `enabled` (defaults true when the column has never been written) so the page can decide whether to render the form at all. - `/api/feedback` POST checks `bugReportEnabled` and returns 503 with a readable error when the admin has switched the feature off, so a scripted client cannot bypass the UI by hitting the endpoint directly. - `/bugreport` page now hides the form and shows the disabled-banner copy when `enabled === false`. - New i18n keys for the disabled state in DE + EN. - Vitest covers the gate: blocked when off, allowed when on, allowed when row absent. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 5 +- messages/en.json | 5 +- src/app/api/bugreport/status/route.ts | 12 ++- src/app/api/feedback/__tests__/route.test.ts | 106 +++++++++++++++++++ src/app/api/feedback/route.ts | 12 +++ src/app/bugreport/page.tsx | 27 +++++ 6 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 src/app/api/feedback/__tests__/route.test.ts diff --git a/messages/de.json b/messages/de.json index 52c22e357..0e35b2b9c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1609,7 +1609,10 @@ "categoryOther": "Sonstiges Feedback", "bugTitlePlaceholder": "Kurze Zusammenfassung (optional)", "githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.", - "internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar." + "internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar.", + "disabledTitle": "Bug-Reports sind deaktiviert", + "disabledBody": "Der Administrator hat Bug-Reports auf diesem Server deaktiviert.", + "disabledError": "Bug-Reports sind vom Administrator deaktiviert." }, "notifications": { "title": "Benachrichtigungen", diff --git a/messages/en.json b/messages/en.json index 7b33c4cf8..87dbb3f06 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1609,7 +1609,10 @@ "categoryOther": "Other feedback", "bugTitlePlaceholder": "Short summary (optional)", "githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.", - "internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server." + "internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server.", + "disabledTitle": "Bug reports are turned off", + "disabledBody": "The administrator has disabled bug reports on this server.", + "disabledError": "Bug reports are disabled by the administrator." }, "notifications": { "title": "Notifications", diff --git a/src/app/api/bugreport/status/route.ts b/src/app/api/bugreport/status/route.ts index d933900b3..1f5d56fac 100644 --- a/src/app/api/bugreport/status/route.ts +++ b/src/app/api/bugreport/status/route.ts @@ -29,7 +29,11 @@ export const GET = apiHandler(async () => { const appSettings = await prisma.appSettings.findUnique({ where: { id: "singleton" }, - select: { githubIssueTokenEncrypted: true, githubIssueRepo: true }, + select: { + githubIssueTokenEncrypted: true, + githubIssueRepo: true, + bugReportEnabled: true, + }, }); const hasToken = Boolean( @@ -39,11 +43,13 @@ export const GET = apiHandler(async () => { appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO, ); const configured = hasToken && hasRepo; + // Default ON when the column has never been written. + const enabled = appSettings?.bugReportEnabled !== false; annotate({ action: { name: "bugreport.status" }, - meta: { configured, has_token: hasToken, has_repo: hasRepo }, + meta: { configured, enabled, has_token: hasToken, has_repo: hasRepo }, }); - return apiSuccess({ configured, isAdmin: user.role === "ADMIN" }); + return apiSuccess({ configured, enabled, isAdmin: user.role === "ADMIN" }); }); diff --git a/src/app/api/feedback/__tests__/route.test.ts b/src/app/api/feedback/__tests__/route.test.ts new file mode 100644 index 000000000..48af20465 --- /dev/null +++ b/src/app/api/feedback/__tests__/route.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ + user: { id: "u-1", email: "test@example.com", role: "USER" }, + session: { id: "s-1" }, + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn() }, + feedback: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn(async () => undefined), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +function jsonRequest(body: unknown): Request { + return new Request("http://localhost/api/feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/feedback bug-report toggle gate", () => { + it("returns 503 when bugReportEnabled is false", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + bugReportEnabled: false, + } as never); + + const res = await POST( + jsonRequest({ + category: "BUG", + subject: "test", + description: "Anything at least ten characters long.", + }), + ); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/disabled/i); + // Must not even attempt to create the row when the toggle is off. + expect(prisma.feedback.create).not.toHaveBeenCalled(); + }); + + it("creates feedback when bugReportEnabled is true", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + bugReportEnabled: true, + } as never); + vi.mocked(prisma.feedback.create).mockResolvedValue({ + id: "fb-1", + createdAt: new Date(), + category: "BUG", + status: "NEW", + } as never); + + const res = await POST( + jsonRequest({ + category: "BUG", + subject: "test", + description: "Anything at least ten characters long.", + }), + ); + expect(res.status).toBe(201); + expect(prisma.feedback.create).toHaveBeenCalledTimes(1); + }); + + it("creates feedback when bugReportEnabled has never been written (default ON)", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + vi.mocked(prisma.feedback.create).mockResolvedValue({ + id: "fb-2", + createdAt: new Date(), + category: "BUG", + status: "NEW", + } as never); + + const res = await POST( + jsonRequest({ + category: "BUG", + subject: "test", + description: "Anything at least ten characters long.", + }), + ); + expect(res.status).toBe(201); + expect(prisma.feedback.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts index fb6a138e7..c3923b684 100644 --- a/src/app/api/feedback/route.ts +++ b/src/app/api/feedback/route.ts @@ -23,6 +23,18 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many submissions — please try again later", 429); } + // The admin "Bug reports" toggle gates *every* feedback submission, not + // just the GitHub-promotion path. Previously the gate only lived in the + // legacy `/api/bugreport` route which the form does not even call — + // so flipping the toggle off had no effect on user-visible behaviour. + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { bugReportEnabled: true }, + }); + if (settings && settings.bugReportEnabled === false) { + return apiError("Bug reports are disabled by the administrator", 503); + } + const { data: body, error: jsonError } = await safeJson(request); if (jsonError) return jsonError; diff --git a/src/app/bugreport/page.tsx b/src/app/bugreport/page.tsx index 588e0e8f0..a62291a7a 100644 --- a/src/app/bugreport/page.tsx +++ b/src/app/bugreport/page.tsx @@ -25,6 +25,7 @@ import { queryKeys } from "@/lib/query-keys"; interface BugReportStatus { configured: boolean; + enabled: boolean; isAdmin: boolean; } @@ -115,6 +116,32 @@ export default function BugReportPage() { ); } + if (status && status.enabled === false) { + return ( +
+
+

+ {t("bugreport.title")} +

+

+ {t("bugreport.subtitle")} +

+
+
+ +
+

+ {t("bugreport.disabledTitle")} +

+

+ {t("bugreport.disabledBody")} +

+
+
+
+ ); + } + return (