merge: remove the bug-report integration for v1.23

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/app/admin/[section]/renderer.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:47:24 +02:00
commit b271a39fe4
68 files changed
+115 -3344

No files matched your search

@@ -1,93 +0,0 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PATCH /api/admin/feedback/[id].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
feedback: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PATCH } from "../route";
import { getSession } from "@/lib/auth/session";
const SESSION_ADMIN = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
};
function patchReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/admin/feedback/f1", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = { params: Promise.resolve({ id: "f1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_ADMIN as never);
});
describe("PATCH /api/admin/feedback/[id] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `status` enum + adminNote too long.
const res = await PATCH(
patchReq({ status: "junk", adminNote: "x".repeat(10000) }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
// The schema has only two knobs (status + adminNote), so the strict
// 3-issue ceiling isn't reachable. Multi-issue contract is pinned at
// ≥ 2 by the case above.
const res = await PATCH(
patchReq({ status: 999, adminNote: 123 }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
@@ -1,96 +0,0 @@
/**
* POST /api/admin/feedback/[id]/github — promote feedback to a GitHub Issue.
*
* Reuses publishFeedbackToGithub() so the issue body matches the legacy
* /api/bugreport flow. Idempotent: if gitHubIssueUrl is already set we return
* the existing URL instead of creating a duplicate.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
publishFeedbackToGithub,
GithubPublishError,
} from "@/lib/feedback/publish-github";
import type { NextRequest } from "next/server";
export const POST = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user: admin } = await requireAdmin();
const { id } = await ctx.params;
const feedback = await prisma.feedback.findUnique({
where: { id },
select: {
id: true,
category: true,
subject: true,
description: true,
gitHubIssueUrl: true,
screenshotBase64: true,
metadata: true,
user: { select: { username: true } },
},
});
if (!feedback) return apiError("Feedback not found", 404);
if (feedback.gitHubIssueUrl) {
return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409);
}
// Atomic claim to prevent concurrent admin clicks from creating two
// duplicate GitHub issues. Only the first caller whose updateMany
// matches (gitHubIssueUrl still null) proceeds. We write a PENDING
// sentinel now and replace it with the real URL after the API call.
const PENDING = "pending://creating";
const claim = await prisma.feedback.updateMany({
where: { id, gitHubIssueUrl: null },
data: { gitHubIssueUrl: PENDING },
});
if (claim.count !== 1) {
return apiError("Already being published by another request", 409);
}
try {
const result = await publishFeedbackToGithub({
category: feedback.category,
subject: feedback.subject,
description: feedback.description,
username: feedback.user?.username ?? "anonymous",
metadata: feedback.metadata,
});
await prisma.feedback.update({
where: { id },
data: { gitHubIssueUrl: result.issueUrl },
});
await auditLog("admin.feedback.github_publish", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { feedbackId: id, issueNumber: result.issueNumber },
});
annotate({
action: { name: "admin.feedback.github_publish" },
meta: { feedback_id: id, issue_number: result.issueNumber },
});
return apiSuccess({ issueUrl: result.issueUrl });
} catch (err) {
// Release the PENDING claim so a retry can succeed.
await prisma.feedback
.updateMany({
where: { id, gitHubIssueUrl: PENDING },
data: { gitHubIssueUrl: null },
})
.catch(() => undefined);
if (err instanceof GithubPublishError) {
return apiError(err.message, err.status);
}
throw err;
}
},
);
-87
View File
@@ -1,87 +0,0 @@
/**
* PATCH /api/admin/feedback/[id] — change status or admin note.
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
getClientIp,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { updateFeedbackSchema } from "@/lib/validations/feedback";
import type { NextRequest } from "next/server";
export const PATCH = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user } = await requireAdmin();
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateFeedbackSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const before = await prisma.feedback.findUnique({ where: { id } });
if (!before) return apiError("Feedback not found", 404);
const updated = await prisma.feedback.update({
where: { id },
data: parsed.data,
select: {
id: true,
status: true,
adminNote: true,
updatedAt: true,
},
});
await auditLog("admin.feedback.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
feedbackId: id,
before: { status: before.status, adminNote: before.adminNote },
after: parsed.data,
},
});
annotate({
action: { name: "admin.feedback.update" },
meta: { feedback_id: id },
});
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user } = await requireAdmin();
const { id } = await ctx.params;
const updated = await prisma.feedback.update({
where: { id },
data: { status: "ARCHIVED" },
select: { id: true, status: true },
});
await auditLog("admin.feedback.archive", {
userId: user.id,
ipAddress: getClientIp(request),
details: { feedbackId: id },
});
return apiSuccess(updated);
},
);
-67
View File
@@ -1,67 +0,0 @@
/**
* Admin feedback inbox. Lists all feedback with status filter.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
import type { FeedbackStatus } from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
await requireAdmin();
const url = new URL(request.url);
const status = url.searchParams.get("status") as FeedbackStatus | null;
const limit = Math.min(
100,
Math.max(1, Number(url.searchParams.get("limit") ?? 50)),
);
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
const where = status ? { status } : {};
const [items, total, counts] = await Promise.all([
prisma.feedback.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
select: {
id: true,
userId: true,
email: true,
category: true,
subject: true,
description: true,
status: true,
adminNote: true,
gitHubIssueUrl: true,
metadata: true,
screenshotBase64: true,
createdAt: true,
updatedAt: true,
user: { select: { username: true } },
},
}),
prisma.feedback.count({ where }),
prisma.feedback.groupBy({
by: ["status"],
_count: { _all: true },
}),
]);
annotate({
action: { name: "admin.feedback.list" },
meta: { total, filtered_status: status },
});
const countsByStatus = Object.fromEntries(
counts.map((c) => [c.status, c._count._all]),
) as Record<FeedbackStatus, number>;
return apiSuccess({
items,
meta: { total, limit, offset, countsByStatus },
});
});
@@ -98,7 +98,6 @@ describe("GET /api/admin/settings", () => {
expect(body.data.registrationEnabled).toBe(true);
expect(body.data.defaultLocale).toBe("de");
expect(body.data.webPushVapidConfigured).toBe(false);
expect(body.data.bugReportConfigured).toBe(false);
expect(body.data.glitchtipEnabled).toBe(false);
});
@@ -120,8 +119,6 @@ describe("GET /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: "production",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "token-blob",
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -129,15 +126,12 @@ describe("GET /api/admin/settings", () => {
const res = await GET();
const body = (await res.json()) as { data: Record<string, unknown> };
// The two configured-flags must be true …
// The configured flag must be true …
expect(body.data.webPushVapidConfigured).toBe(true);
expect(body.data.bugReportConfigured).toBe(true);
// … but raw encrypted blobs must never reach the client.
const serialized = JSON.stringify(body.data);
expect(serialized).not.toContain("secret-blob");
expect(serialized).not.toContain("token-blob");
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
});
});
@@ -185,8 +179,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: "production",
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -227,8 +219,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -257,8 +247,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -292,8 +280,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: true,
glitchtipDsn: "https://abc@glitchtip.example.com/1",
glitchtipEnvironment: "staging",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "enc(gh-token)",
reminderLateMinutes: 30,
reminderMissedMinutes: 90,
moodLogGlobal: false,
@@ -317,8 +303,6 @@ describe("PUT /api/admin/settings", () => {
umamiWebsiteId: "site-uuid",
glitchtipDsn: "https://abc@glitchtip.example.com/1",
glitchtipEnvironment: "staging",
bugReportRepo: "owner/repo",
bugReportToken: "gh-token",
reminderLateMinutes: 30,
reminderMissedMinutes: 90,
}),
@@ -336,8 +320,6 @@ describe("PUT /api/admin/settings", () => {
umamiWebsiteId: "site-uuid",
glitchtipDsn: "https://abc@glitchtip.example.com/1",
glitchtipEnvironment: "staging",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "enc(gh-token)",
webPushVapidPublicKey: "vapid-pub",
webPushVapidSubject: "mailto:ops@healthlog.dev",
webPushVapidPrivateKeyEncrypted: "enc(secret)",
@@ -345,7 +327,6 @@ describe("PUT /api/admin/settings", () => {
reminderMissedMinutes: 90,
});
expect(encrypt).toHaveBeenCalledWith("secret");
expect(encrypt).toHaveBeenCalledWith("gh-token");
});
it("clears nullable string fields when an empty string is submitted", async () => {
@@ -366,8 +347,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -381,7 +360,6 @@ describe("PUT /api/admin/settings", () => {
umamiWebsiteId: "",
glitchtipDsn: "",
glitchtipEnvironment: "",
bugReportRepo: "",
}),
);
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
@@ -392,39 +370,9 @@ describe("PUT /api/admin/settings", () => {
umamiWebsiteId: null,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
});
});
it("clears the encrypted bug-report token when clear flag is set", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "en",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
} as never);
await PUT(jsonReq({ clearBugReportToken: true }));
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
});
it("returns 415 when content-type is not JSON", async () => {
const r = new NextRequest("http://localhost/api/admin/settings", {
method: "PUT",
@@ -454,9 +402,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
bugReportEnabled: true,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -486,9 +431,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
bugReportEnabled: true,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
-35
View File
@@ -16,7 +16,6 @@ import {
invalidateServerDefaultTimezone,
isValidTimezone,
} from "@/lib/tz/resolver";
import { invalidateAppSettings } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -48,11 +47,6 @@ export const GET = apiHandler(async () => {
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
glitchtipDsn: settings?.glitchtipDsn ?? null,
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
bugReportRepo: settings?.githubIssueRepo ?? null,
bugReportConfigured: Boolean(
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
),
bugReportEnabled: settings?.bugReportEnabled ?? true,
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
@@ -92,7 +86,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
"umamiEnabled",
"glitchtipEnabled",
"moodLogGlobal",
"bugReportEnabled",
] as const;
for (const field of booleanFields) {
if (data[field] !== undefined) {
@@ -171,24 +164,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
auditDetails.glitchtipEnvironment = value || null;
}
// Bug report
if (data.bugReportRepo !== undefined) {
const repo = data.bugReportRepo.trim();
updates.githubIssueRepo = repo || null;
auditDetails.bugReportRepo = repo || null;
}
if (data.bugReportToken !== undefined) {
const token = data.bugReportToken.trim();
if (token) {
updates.githubIssueTokenEncrypted = encrypt(token);
auditDetails.bugReportTokenUpdated = true;
}
}
if (data.clearBugReportToken === true) {
updates.githubIssueTokenEncrypted = null;
auditDetails.bugReportTokenUpdated = false;
}
// Numeric thresholds
if (data.reminderLateMinutes !== undefined) {
updates.reminderLateMinutes = data.reminderLateMinutes;
@@ -233,11 +208,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateServerDefaultTimezone();
}
// v1.4.34 IW-G — bust the bug-report status cache (global singleton)
// so the next read reflects the new GitHub-token / bug-report-enabled
// shape. Cheap call — the cache holds at most 10 entries.
invalidateAppSettings();
await auditLog("admin.settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -264,11 +234,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
bugReportRepo: settings.githubIssueRepo,
bugReportConfigured: Boolean(
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
),
bugReportEnabled: settings.bugReportEnabled,
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
@@ -9,7 +9,6 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { invalidateAppSettings } from "@/lib/cache/invalidate";
import { z } from "zod/v4";
import { NextRequest } from "next/server";
@@ -114,8 +113,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
invalidateAppSettings();
await auditLog("admin.web_push_vapid.generate", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -89,11 +89,10 @@ describe("GET /api/admin/status (integration health summary)", () => {
umami: null,
glitchtip: null,
webPush: null,
bugReport: null,
});
});
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
it("aggregates configured + enabled flags for umami / glitchtip / web-push", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
umamiScriptUrl: "https://x/script.js",
umamiWebsiteId: "site-1",
@@ -103,8 +102,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
webPushVapidPublicKey: "pub",
webPushVapidPrivateKeyEncrypted: "secret-blob",
webPushVapidSubject: "mailto:a@b.com",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "token-blob",
} as never);
const res = await GET();
@@ -121,7 +118,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
enabled: false,
});
expect(body.data.integrations.webPush).toEqual({ configured: true });
expect(body.data.integrations.bugReport).toEqual({ configured: true });
// Regression: encrypted blobs MUST never appear in the response envelope.
const serialized = JSON.stringify(body);
-5
View File
@@ -56,10 +56,6 @@ export const GET = apiHandler(async () => {
appSettings?.webPushVapidPrivateKeyEncrypted &&
appSettings?.webPushVapidSubject,
);
const bugReportConfigured = Boolean(
appSettings?.githubIssueRepo && appSettings?.githubIssueTokenEncrypted,
);
const workerStatus = getWorkerStatus();
return apiSuccess({
@@ -89,7 +85,6 @@ export const GET = apiHandler(async () => {
}
: null,
webPush: webPushConfigured ? { configured: true } : null,
bugReport: bugReportConfigured ? { configured: true } : null,
},
});
});
@@ -1,99 +0,0 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/bugreport.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (s: string) => s.replace(/^enc-/, ""),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/bugreport", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
githubIssueTokenEncrypted: "enc-token",
githubIssueRepo: "owner/repo",
bugReportEnabled: true,
} as never);
});
describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// description too short + screenshot doesn't match data-URL regex.
const res = await POST(
postReq({ description: "tiny", screenshot: "not-a-data-url" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
// Schema has only two knobs (description, screenshot). Pin contract
// on ≥ 2.
const res = await POST(postReq({ description: 123, screenshot: 999 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
-193
View File
@@ -1,193 +0,0 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { safeFetch } from "@/lib/safe-fetch";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
// Strict image/* data URL (no SVG — it can carry inline JS).
const SCREENSHOT_DATA_URL =
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
const bugReportSchema = z.object({
description: z.string().min(10, "Description too short").max(5000),
screenshot: z
.string()
.max(7_000_000, "Screenshot too large (max 5 MB)")
.regex(
SCREENSHOT_DATA_URL,
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
)
.optional(),
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 3 bug reports per hour", 429);
}
annotate({ action: { name: "bugreport.submit" } });
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
githubIssueTokenEncrypted: true,
githubIssueRepo: true,
bugReportEnabled: true,
},
});
// v1.4.3: explicit toggle. When the admin disables bug reports we
// return 503 immediately — the client already renders a "not
// configured" UI for that status, so the report button gracefully
// disappears without needing additional client-side wiring.
if (appSettings && appSettings.bugReportEnabled === false) {
return apiError("Bug reports are disabled by the administrator", 503);
}
let configuredToken: string | null = null;
if (appSettings?.githubIssueTokenEncrypted) {
try {
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
} catch {
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
}
}
const ghToken = configuredToken || process.env.GITHUB_ISSUE_TOKEN;
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
if (!ghToken || !ghRepo) {
// 503 instead of 500: the client keys off status to render the "not
// configured" screen. The user also sees a clear message rather than
// "Internal server error".
return apiError(
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
503,
);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024 * 1024,
});
if (jsonError) return jsonError;
const parsed = bugReportSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { description, screenshot } = parsed.data;
// GitHub issues are maintainer-facing and the repo's primary language is English.
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
const now = new Date();
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
const title = `Bug Report – ${dateStr}`;
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
// targets that start with dangerous schemes like javascript: or data:.
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
const safeDescription = description
.replace(/<[^>]*>/g, "") // strip HTML tags
.replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes
.replace(/```/g, "\\`\\`\\`")
.slice(0, 5000);
// Wrap the user's description in a fenced code block so any residual
// markdown renders literally rather than as executable markup.
let issueBody = `**Reported by:** ${safeUsername}\n`;
issueBody += `**Date:** ${dateStr}\n\n`;
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
issueBody += `\n---\n*Created via HealthLog bug report*`;
// If screenshot provided, upload to GitHub as a gist or include as base64
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
if (screenshot) {
issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`;
}
// Create the issue
const issueRes = await safeFetch(
`https://api.github.com/repos/${ghRepo}/issues`,
{
method: "POST",
headers: {
Authorization: `Bearer ${ghToken}`,
"Content-Type": "application/json",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify({
title: `[Bug] ${title}`,
body: issueBody,
labels: ["bug", "user-reported"],
}),
},
);
if (!issueRes.ok) {
const errBody = await issueRes.text();
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
return apiError("Failed to create issue", 500);
}
const issue = (await issueRes.json()) as {
number: number;
html_url: string;
};
// If screenshot, upload as a comment
if (screenshot) {
// Extract just the base64 part
const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, "");
const ext = screenshot.startsWith("data:image/png") ? "png" : "jpg";
// Upload screenshot as issue comment with embedded image
// GitHub doesn't support base64 in comments, so we note the limitation
const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
await safeFetch(
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
{
method: "POST",
headers: {
Authorization: `Bearer ${ghToken}`,
"Content-Type": "application/json",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify({ body: commentBody }),
},
);
}
await auditLog("bugreport.submit", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
issueNumber: issue.number,
hasScreenshot: Boolean(screenshot),
},
});
annotate({ meta: { issue_number: issue.number } });
return apiSuccess({
issueNumber: issue.number,
issueUrl: issue.html_url,
});
});
-83
View File
@@ -1,83 +0,0 @@
/**
* Returns whether bug report submission is configured (GitHub token + repo).
*
* The bug report page uses this to gate its UI so users don't hit a silent
* 500 when the admin hasn't set up the GitHub integration. Only two booleans
* escape: a single `configured` flag and `isAdmin` so the page can render
* the admin-specific copy + admin link. Token/repo granularity is kept
* server-side (log annotation only) so we don't hand regular users a probe.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
interface BugreportStatusCacheValue {
configured: boolean;
enabled: boolean;
hasToken: boolean;
hasRepo: boolean;
}
async function buildBugreportStatusValue(): Promise<BugreportStatusCacheValue> {
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
githubIssueTokenEncrypted: true,
githubIssueRepo: true,
bugReportEnabled: true,
},
});
const hasToken = Boolean(
appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN,
);
const hasRepo = Boolean(
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;
return { configured, enabled, hasToken, hasRepo };
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// No per-request rate limit: the payload rides the singleton server
// cache below, so a cache hit costs zero Postgres round-trips — while
// the former rate-limit bucket was an unconditional Postgres UPSERT
// per request, i.e. the limiter WAS the per-request DB load it claimed
// to prevent. The route stays authenticated; the only uncached work is
// one `appSettings` read per 10 min (the bucket's TTL) process-wide.
// Cache the global app-settings shape on a singleton key (per blueprint §3).
// `isAdmin` lives outside the cache because it varies per request. The
// `invalidateAppSettings()` helper called from
// `PATCH /api/admin/app-settings` evicts this entry when bug-report
// configuration changes.
const status = await cached(
caches.bugreportStatus as ServerCache<BugreportStatusCacheValue>,
"singleton",
() => buildBugreportStatusValue(),
annotate,
);
annotate({
action: { name: "bugreport.status" },
meta: {
configured: status.configured,
enabled: status.enabled,
has_token: status.hasToken,
has_repo: status.hasRepo,
},
});
return apiSuccess({
configured: status.configured,
enabled: status.enabled,
isAdmin: user.role === "ADMIN",
});
});
@@ -1,155 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => 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.",
}) as never,
);
expect(res.status).toBe(503);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/disabled/i);
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.",
}) as never,
);
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.",
}) as never,
);
expect(res.status).toBe(201);
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/feedback — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
bugReportEnabled: true,
} as never);
// subject too short + description too short.
const res = await POST(
jsonRequest({
category: "BUG",
subject: "x",
description: "short",
}) as never,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
bugReportEnabled: true,
} as never);
// bad subject + bad description + bad screenshot.
const res = await POST(
jsonRequest({
category: "BUG",
subject: "x",
description: "short",
screenshot: "not-a-data-url",
}) as never,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
-123
View File
@@ -1,123 +0,0 @@
/**
* POST /api/feedback — user submits feedback. Always works, no GitHub needed.
* GET /api/feedback — user's own feedback history (paginated).
*
* Rate-limited at 5 per hour per user. Screenshots are stored as base64 in
* the database (≤5 MB each).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
getClientIp,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { createFeedbackSchema } from "@/lib/validations/feedback";
import { Prisma } from "@/generated/prisma/client";
import type { NextRequest } from "next/server";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000);
if (!rl.allowed) {
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, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createFeedbackSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const feedback = await prisma.feedback.create({
data: {
userId: user.id,
email: user.email ?? null,
category: parsed.data.category,
subject: parsed.data.subject,
description: parsed.data.description,
screenshotBase64: parsed.data.screenshot ?? null,
metadata:
parsed.data.metadata !== undefined
? (parsed.data.metadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
},
select: { id: true, createdAt: true, category: true, status: true },
});
await auditLog("feedback.submit", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
feedbackId: feedback.id,
category: feedback.category,
hasScreenshot: Boolean(parsed.data.screenshot),
},
});
annotate({
action: { name: "feedback.submit" },
meta: {
feedback_id: feedback.id,
category: feedback.category,
},
});
return apiSuccess(feedback, 201);
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const url = new URL(request.url);
const limit = Math.min(
50,
Math.max(1, Number(url.searchParams.get("limit") ?? 25)),
);
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
const [items, total] = await Promise.all([
prisma.feedback.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
select: {
id: true,
category: true,
subject: true,
status: true,
adminNote: true,
gitHubIssueUrl: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.feedback.count({ where: { userId: user.id } }),
]);
return apiSuccess({ items, meta: { total, limit, offset } });
});
@@ -190,7 +190,7 @@ describe("GET /api/gamification/achievements — hidden achievement redaction",
for (const entry of hiddenLocked) {
// Trigger semantics scrubbed
expect(entry.metric ?? "").not.toMatch(
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip|bugReport)Count/,
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip)Count/,
);
// i18n keys scrubbed
expect(entry.titleKey ?? "").not.toContain("hiddenNightOwl");
+5 -6
View File
@@ -66,12 +66,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
// Destroy all sessions first
await destroyAllSessions(userId);
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have
// `onDelete: SetNull` in the schema, which keeps PII (free-text symptom
// descriptions, IP addresses, login city geo) attached to the record after
// the user is deleted. We explicitly purge them inside the same logical
// operation so account deletion is genuinely complete erasure.
await prisma.feedback.deleteMany({ where: { userId } });
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have
// `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login
// city geo) attached to the record after the user is deleted. We explicitly
// purge them inside the same logical operation so account deletion is
// genuinely complete erasure.
await prisma.auditLog.deleteMany({ where: { userId } });
// Delete user — all other related data is removed via onDelete: Cascade