diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 102729345..0d71bcd34 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -28,7 +28,6 @@ const CONTRACT_STUBS: Readonly> = { "admin.pulse_day_mean.get", "admin.pulse_day_mean.post", ], - "admin/support-report/route.ts": ["admin.support_report.get"], "anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"], "anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"], "anamnesis/life-circumstances/ai-sections/route.ts": [ @@ -64,7 +63,7 @@ const CONTRACT_STUBS: Readonly> = { }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 24; +const REGISTER_SIZE = 23; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) diff --git a/src/app/api/admin/support-report/__tests__/route.test.ts b/src/app/api/admin/support-report/__tests__/route.test.ts new file mode 100644 index 000000000..e74b24c18 --- /dev/null +++ b/src/app/api/admin/support-report/__tests__/route.test.ts @@ -0,0 +1,277 @@ +/** + * `GET /api/admin/support-report` end to end over a mocked database: the + * collector, the renderer and the redaction together. Accounts are seeded + * with distinctive emails, ids, usernames and names, and the failures carry + * a token, a URL and a host in their messages; none of it may reach the text. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const USERS = [ + { + id: "cmownerabcdefghijklmnopq", + username: "ownerperson", + email: "owner.person@example.org", + fullName: "Olive Owner", + aiUsageLevel: null, + }, + { + id: "cmsecondabcdefghijklmnop", + username: "secondperson", + email: "second.person@mailbox.org", + fullName: "Sam Second", + aiUsageLevel: "FULL", + }, +]; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findMany: vi.fn(async () => USERS), + groupBy: vi.fn(async () => [ + { aiProvider: "LOCAL", _count: { _all: 1 } }, + { aiProvider: null, _count: { _all: 1 } }, + ]), + }, + appSettings: { + findUnique: vi.fn(async () => ({ + glitchtipEnabled: true, + glitchtipDsn: "https://key@glitchtip.example.net/1", + webPushVapidPublicKey: null, + webPushVapidPrivateKeyEncrypted: null, + adminAiKeyEncrypted: "v1:ciphertext", + adminCodexConnectionStatus: "disconnected", + aiUsageDefaultLevel: "STANDARD", + aiUsageMaxLevel: "FULL", + })), + }, + $queryRaw: vi.fn(async (strings: TemplateStringsArray) => { + const sql = strings.join("?"); + if (sql.includes("server_version")) { + return [ + { version: "16.4", timezone: "Etc/UTC", size: BigInt(52428800) }, + ]; + } + if (sql.includes("migration_name")) { + return [ + { + migration_name: "0410_relation_links", + finished_at: new Date("2026-10-15T08:00:00Z"), + }, + ]; + } + if (sql.includes("finished_at IS NULL")) return [{ count: 0 }]; + if (sql.includes("pgboss.job")) { + return [{ at: new Date("2026-10-15T09:59:00Z") }]; + } + return []; + }), + integrationWatch: { + findMany: vi.fn(async () => [ + { + userId: "cmsecondabcdefghijklmnop", + integration: "nightscout", + incidentReason: "unexpected_silence", + incidentOpenedAt: new Date("2026-10-14T00:00:00Z"), + alertedAt: null, + }, + ]), + }, + providerHealth: { + findMany: vi.fn(async () => [ + { + providerType: "LOCAL", + lastResult: "hard_failed", + lastStatus: 502, + lastFailureAt: new Date("2026-10-15T07:00:00Z"), + nextRetryAt: new Date(Date.now() + 60_000), + }, + ]), + }, + pushAttempt: { + groupBy: vi.fn(async (args: { by: string[] }) => + args.by.includes("result") + ? [ + { channel: "NTFY", result: "ok", _count: { _all: 9 } }, + { channel: "NTFY", result: "error", _count: { _all: 2 } }, + ] + : [{ channel: "NTFY", reason: "http_500", _count: { _all: 2 } }], + ), + }, + dataBackup: { + aggregate: vi.fn(async () => ({ + _max: { createdAt: new Date("2026-10-12T03:00:00Z") }, + })), + count: vi.fn(async () => 2), + }, + offhostBackupState: { + findMany: vi.fn(async () => [ + { lastSuccessAt: new Date("2026-10-15T02:00:00Z") }, + { lastSuccessAt: null }, + ]), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/jobs/job-failures", () => ({ + JOB_FAILURE_WINDOW_HOURS: 72, + readFailingQueues: vi.fn(async () => [ + { + queue: "withings-sync", + failures: 4, + lastFailedAt: "2026-10-15T09:00:00.000Z", + lastError: + "TypeError: fetch https://wbsapi.withings.net/v2?access_token=abc failed for owner.person@example.org", + }, + ]), + readLastQueueRun: vi.fn(async (queue: string) => + queue === "data-restore-drill" + ? { + at: "2026-10-01T04:11:00.000Z", + state: "failed", + error: "connect ECONNREFUSED 10.0.0.12:9000 (s3.nas.lan)", + } + : null, + ), +})); + +vi.mock("@/lib/integrations/connection-health-load", () => ({ + loadConnectionHealth: vi.fn(async (userId: string) => { + const empty = { + withings: null, + whoop: null, + fitbit: null, + "google-health": null, + polar: null, + oura: null, + strava: null, + nightscout: null, + "apple-health": null, + }; + if (userId === "cmownerabcdefghijklmnopq") { + return { + ...empty, + withings: { state: "ok", reason: "data_arriving" }, + }; + } + return { + ...empty, + withings: { state: "action_required", reason: "reauth_required" }, + nightscout: { state: "degraded", reason: "unexpected_silence" }, + }; + }), +})); + +vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => false })); + +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer"; +import { GET } from "../route"; + +const FORBIDDEN = [ + ...USERS.flatMap((u) => [u.id, u.username, u.email, u.fullName]), + "wbsapi.withings.net", + "access_token", + "glitchtip.example.net", + "10.0.0.12", + "s3.nas.lan", + "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc", + "hlk_leaked_token", +]; + +beforeEach(() => { + vi.mocked(requireAdmin).mockReset(); + clearLogBuffer(); +}); + +async function report(): Promise<{ generatedAt: string; text: string }> { + const res = await GET(); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +describe("GET /api/admin/support-report", () => { + it("refuses a non-admin before reading anything", async () => { + vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden")); + await expect(GET()).rejects.toThrow("Forbidden"); + }); + + it("answers the redacted report with every section", async () => { + appendLogEvent({ + timestamp: "2026-10-15T09:40:00.000Z", + duration_ms: 20, + request_id: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc", + trace_id: "t", + level: "error", + kind: "http", + service: "healthlog", + environment: "test", + action: { name: "measurement.batch.ingest" }, + auth: { user_id: "cmownerabcdefghijklmnopq" }, + error: { + type: "TypeError", + message: "Bearer hlk_leaked_token for owner.person@example.org", + }, + }); + + const { generatedAt, text } = await report(); + expect(Date.parse(generatedAt)).not.toBeNaN(); + for (const heading of [ + "## Build", + "## Database", + "## Configuration", + "## Jobs", + "## Connections", + "## AI", + "## Notifications (7 days)", + "## Backups", + "## Recent warnings and errors (this process)", + ]) { + expect(text).toContain(heading); + } + expect(text).toContain("- PostgreSQL: 16.4"); + expect(text).toContain("0410_relation_links"); + expect(text).toContain("- GlitchTip: yes"); + expect(text).toContain("withings-sync: 4 failed"); + expect(text).toContain("(TypeError)"); + expect(text).toContain( + "- withings: action_required / reauth_required: 1 (Account 2)", + ); + expect(text).toContain("- withings: ok / data_arriving: 1"); + expect(text).toContain("nightscout: unexpected_silence since"); + expect(text).toContain("Account 2"); + expect(text).toContain( + "- Accounts by usage level: FULL 1, instance default 1", + ); + expect(text).toContain( + "LOCAL: ok 0, failing 1, credential refused 0, paused 1", + ); + expect(text).toContain("- NTFY: ok 9, failed 2, skipped 0; http_500 2"); + expect(text).toContain( + "Last restore drill: failed at 2026-10-01T04:11:00.000Z (ECONNREFUSED)", + ); + expect(text).toContain("- error measurement.batch.ingest: 1x"); + + for (const value of FORBIDDEN) { + expect(text.toLowerCase(), value).not.toContain(value.toLowerCase()); + } + }); +}); diff --git a/src/app/api/admin/support-report/route.ts b/src/app/api/admin/support-report/route.ts index 08fec25cb..472f8e886 100644 --- a/src/app/api/admin/support-report/route.ts +++ b/src/app/api/admin/support-report/route.ts @@ -5,13 +5,32 @@ * backups and the most frequent recent error actions. Never an email address, * a user id, a value, a token or a foreign host name; accounts appear as * "Account 1..n". Admin only, cookie session only. + * + * What goes in and how it is redacted lives in `src/lib/support/report.ts`; + * the redaction guard is `src/lib/support/__tests__/support-report-redaction.test.ts`. */ import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + buildSupportReportText, + collectSupportReportFacts, +} from "@/lib/support/report"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { await requireAdmin(); - return notImplemented("admin.support_report.get"); + annotate({ action: { name: "admin.support_report.get" } }); + + const { facts, context } = await collectSupportReportFacts(); + const text = buildSupportReportText(facts, context); + + annotate({ + meta: { + support_report_accounts: facts.accounts.total, + support_report_bytes: text.length, + }, + }); + return apiSuccess({ generatedAt: facts.generatedAt, text }); }); diff --git a/src/lib/__tests__/api-handler-error-request-id.test.ts b/src/lib/__tests__/api-handler-error-request-id.test.ts index a76c73493..ca32c21b0 100644 --- a/src/lib/__tests__/api-handler-error-request-id.test.ts +++ b/src/lib/__tests__/api-handler-error-request-id.test.ts @@ -29,7 +29,8 @@ function call( handler: () => Promise, headers: Record = {}, ) { - return apiHandler(handler)( + const route = apiHandler(async (_request: NextRequest) => handler()); + return route( new NextRequest("http://localhost/api/probe", { headers }), ); } diff --git a/src/lib/support/__tests__/support-report-redaction.test.ts b/src/lib/support/__tests__/support-report-redaction.test.ts new file mode 100644 index 000000000..d21046b33 --- /dev/null +++ b/src/lib/support/__tests__/support-report-redaction.test.ts @@ -0,0 +1,309 @@ +/** + * Redaction guard for the operator's support report. + * + * The report is pasted into public issues. This seeds every free-text slot it + * carries with something it must never print (an account's email, id and + * username, a token, a URL, a foreign host, an address, a request id) and + * proves two things: + * + * 1. the finished text contains none of them; + * 2. every redaction step is load-bearing: with any single step dropped, + * the seed of its class reaches the output. A step that can be removed + * without a test going red would be a step nobody notices is gone. + */ +import { describe, expect, it } from "vitest"; + +import { + buildSupportReportText, + errorClassOf, + renderSupportReport, + summariseProblemEvents, + type SupportReportFacts, +} from "../report"; +import { + redactSupportText, + SUPPORT_REDACTION_STEPS, + type SupportRedactionContext, +} from "../redact"; +import type { WideEvent } from "@/lib/logging/types"; + +const ACCOUNT_EMAIL = "anna.schmidt@example.org"; +const ACCOUNT_ID = "cmq7x0abcdefghijklmnopqrs"; +const ACCOUNT_USERNAME = "annaschmidt"; +const ACCOUNT_NAME = "Anna Schmidt"; + +/** One seed per step; each is caught by its own step and by no other. */ +const SEEDS = { + "known-values": ACCOUNT_USERNAME, + secrets: "sk-ant-api03-Zq9Xw8Vu7Ts6", + urls: "smb://nas/backups", + emails: "someone@", + ids: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc", + "ip-addresses": "192.168.17.42", + "host-names": "gotify.home.arpa", +} as const; + +const CONTEXT: SupportRedactionContext = { + knownValues: [ACCOUNT_ID, ACCOUNT_USERNAME, ACCOUNT_EMAIL, ACCOUNT_NAME], +}; + +function seededFacts(): SupportReportFacts { + return { + generatedAt: "2026-10-15T10:00:00.000Z", + build: { + version: "1.43.0", + buildSha: "abc1234", + builtAt: "2026-10-15T08:00:00.000Z", + node: "v22.11.0", + processType: "web,worker", + heapLimitMb: 792, + uptimeSeconds: 7260, + }, + database: { + version: "16.4", + timezone: "Etc/UTC", + sizeBytes: 104857600, + lastMigration: "0410_relation_links", + lastMigrationAt: "2026-10-15T08:05:00.000Z", + unfinishedMigrations: 0, + }, + config: [ + ["Secure cookie flag", "yes"], + // A value that should only ever be yes/no, carrying a URL anyway. + ["AI provider types chosen by accounts", `OPENAI 1, ${SEEDS.urls}`], + ], + jobs: { + workerInThisProcess: true, + workerRunning: true, + workerLastHeartbeat: "2026-10-15T09:59:00.000Z", + newestFinishedJobAt: "2026-10-15T09:59:30.000Z", + failing: [ + { + queue: "environment-fetch", + failures: 3, + lastFailedAt: "2026-10-15T09:00:00.000Z", + errorClass: `Error for ${ACCOUNT_EMAIL}`, + }, + ], + }, + accounts: { total: 2, read: 2 }, + connections: [ + { + provider: "withings", + state: "action_required", + reason: "reauth_required", + accounts: [2], + }, + { + provider: "oura", + state: "degraded", + reason: `provider_errors ${SEEDS["ip-addresses"]}`, + accounts: [1], + }, + ], + incidents: [ + { + provider: "nightscout", + reason: `unexpected_silence ${SEEDS["host-names"]}`, + openedAt: "2026-10-14T00:00:00.000Z", + announced: true, + account: 1, + }, + ], + ai: { + defaultLevel: "STANDARD", + maxLevel: "FULL", + levels: [["instance default", 2]], + providers: [ + { + provider: "LOCAL", + ok: 1, + failing: 1, + authFailed: 0, + paused: 0, + lastFailureAt: "2026-10-15T07:00:00.000Z", + lastStatus: 502, + }, + ], + }, + notifications: [ + { + channel: "NTFY", + ok: 10, + error: 2, + skipped: 0, + topReasons: [[`http_500 ${SEEDS.ids}`, 2]], + }, + ], + backups: { + newestLocalAt: "2026-10-12T03:00:00.000Z", + localCopies: 2, + lastLocalRun: { + at: "2026-10-12T03:10:00.000Z", + state: "completed", + errorClass: null, + }, + offhostConfigured: true, + offhostNewestAt: "2026-10-15T02:00:00.000Z", + offhostOldestAt: "2026-10-15T01:00:00.000Z", + offhostAccounts: 2, + offhostNeverStored: 0, + lastOffhostRun: null, + lastRestoreDrill: { + at: "2026-10-01T04:11:00.000Z", + state: "failed", + errorClass: `TypeError Bearer ${SEEDS.secrets}`, + }, + }, + errors: { + ringSize: 3, + ringMax: 1000, + top: [ + { + action: `integration.sync.${ACCOUNT_ID}`, + level: "error", + count: 2, + lastSeen: "2026-10-15T09:30:00.000Z", + errorClass: `${ACCOUNT_USERNAME} Error`, + }, + { + action: "notification.send", + level: "warn", + count: 1, + lastSeen: "2026-10-15T09:20:00.000Z", + errorClass: `someone@mailbox.org ${ACCOUNT_NAME}`, + }, + ], + }, + }; +} + +const FORBIDDEN = [ + ACCOUNT_EMAIL, + ACCOUNT_ID, + ACCOUNT_USERNAME, + ACCOUNT_NAME, + ...Object.values(SEEDS), +]; + +describe("support report redaction", () => { + it("prints none of the seeded values", () => { + const text = buildSupportReportText(seededFacts(), CONTEXT); + for (const value of FORBIDDEN) { + expect(text.toLowerCase(), value).not.toContain(value.toLowerCase()); + } + // The report itself survived: sections, accounts as numbers, dates. + expect(text).toContain("# HealthLog support report"); + expect(text).toContain("Account 2"); + expect(text).toContain("2026-10-15T09:30:00.000Z"); + expect(text).toContain("0410_relation_links"); + expect(text).toContain("environment-fetch"); + }); + + it("the seeds really are in the unredacted text", () => { + const raw = renderSupportReport(seededFacts()); + for (const value of FORBIDDEN) expect(raw).toContain(value); + }); + + it.each(SUPPORT_REDACTION_STEPS.map((s) => s.name))( + "goes red without the %s step", + (name) => { + const seed = SEEDS[name as keyof typeof SEEDS]; + expect(seed, `no seed for step ${name}`).toBeDefined(); + const without = SUPPORT_REDACTION_STEPS.filter((s) => s.name !== name); + const text = redactSupportText( + renderSupportReport(seededFacts()), + CONTEXT, + without, + ); + expect(text.toLowerCase()).toContain(seed.toLowerCase()); + }, + ); + + it("has a seed for every step, so a new step needs one", () => { + expect(Object.keys(SEEDS).sort()).toEqual( + SUPPORT_REDACTION_STEPS.map((s) => s.name).sort(), + ); + }); + + it("leaves dates, versions and clock times alone", () => { + const text = redactSupportText( + "- Node: v22.11.0\n- Built at: 2026-10-15T08:00:00.000Z\n- Version: 1.43.0", + CONTEXT, + ); + expect(text).toContain("v22.11.0"); + expect(text).toContain("2026-10-15T08:00:00.000Z"); + expect(text).toContain("1.43.0"); + }); +}); + +describe("error classes never carry the message", () => { + it("names the class or code and drops the rest", () => { + expect( + errorClassOf( + `TypeError: fetch failed for https://gotify.home.arpa/?token=abc (${ACCOUNT_EMAIL})`, + ), + ).toBe("TypeError"); + expect(errorClassOf("connect ECONNREFUSED 192.168.17.42:5432")).toBe( + "ECONNREFUSED", + ); + expect( + errorClassOf("Invalid `prisma.user.update()` invocation: P2002 unique"), + ).toBe("P2002"); + expect(errorClassOf("Withings answered HTTP 503 for annaschmidt")).toBe( + "503", + ); + expect(errorClassOf("job timed out: did not finish within 1 h")).toBe( + "timeout", + ); + expect(errorClassOf("anything at all, an email a@b.co")).toBe("Error"); + expect(errorClassOf(null)).toBe("unknown"); + }); + + it("groups problem events by action without reading their messages", () => { + const event = (over: Partial): WideEvent => ({ + timestamp: "2026-10-15T09:00:00.000Z", + duration_ms: 5, + request_id: SEEDS.ids, + trace_id: "t", + level: "error", + kind: "http", + service: "healthlog", + environment: "test", + ...over, + }); + const top = summariseProblemEvents([ + event({ + action: { name: "measurement.batch.ingest" }, + error: { + type: "PrismaClientKnownRequestError", + code: "P2002", + message: `duplicate for ${ACCOUNT_EMAIL}`, + }, + }), + event({ + timestamp: "2026-10-15T09:05:00.000Z", + action: { name: "measurement.batch.ingest" }, + error: { type: "Error", message: "Bearer hlk_secret_value_here" }, + }), + event({ + level: "warn", + http: { method: "GET", path: "/x", route: "/x", status: 404 }, + }), + ]); + expect(top[0]).toEqual({ + action: "measurement.batch.ingest", + level: "error", + count: 2, + lastSeen: "2026-10-15T09:05:00.000Z", + errorClass: "Error", + }); + expect(top[1]).toMatchObject({ + action: "http GET 404", + errorClass: "HTTP 404", + }); + expect(JSON.stringify(top)).not.toContain(ACCOUNT_EMAIL); + expect(JSON.stringify(top)).not.toContain("hlk_secret"); + expect(JSON.stringify(top)).not.toContain(SEEDS.ids); + }); +}); diff --git a/src/lib/support/redact.ts b/src/lib/support/redact.ts new file mode 100644 index 000000000..28eb95bd5 --- /dev/null +++ b/src/lib/support/redact.ts @@ -0,0 +1,164 @@ +/** + * Redaction for the support report. + * + * The report is meant to be pasted into a public issue. It is built from + * counts, states and closed codes, so in the ordinary case there is nothing + * here to remove. These steps are the second layer: the free-text fields the + * report does carry (an error class, a delivery reason, an action name) come + * from places a future change could fill with something it should not, and + * the whole rendered text runs through every step before it leaves. + * + * The steps are an ordered list rather than one function so the guard test + * can drop each one in turn and prove it is load-bearing: with any single + * step removed, a seeded value of its class reaches the output. + * + * Never an email address, an account id, a token, a host name, an address or + * a value an account wrote. + */ +import { redactSecrets } from "@/lib/logging/redact"; + +/** Values that belong to this instance's accounts and must never appear. */ +export interface SupportRedactionContext { + /** Email addresses, usernames, display names, account ids. */ + knownValues: readonly string[]; +} + +export interface SupportRedactionStep { + name: string; + apply: (text: string, context: SupportRedactionContext) => string; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Shortest known value replaced. Shorter usernames (`ann`, `max`) are left to + * the structural rules: the report prints no username anywhere, and a + * three-letter match would rewrite ordinary words of the report itself. + */ +const KNOWN_VALUE_MIN_LENGTH = 5; + +/** Top-level domains a host name in a self-hosted setup ends in. */ +const HOST_TLDS = [ + "com", + "net", + "org", + "io", + "dev", + "app", + "cloud", + "info", + "me", + "de", + "at", + "ch", + "eu", + "uk", + "fr", + "es", + "it", + "pl", + "nl", + "kr", + "us", + "local", + "lan", + "home", + "internal", + "arpa", + "localdomain", + "ts", +].join("|"); + +export const SUPPORT_REDACTION_STEPS: readonly SupportRedactionStep[] = [ + { + // Exact values of this instance's accounts, wherever they turn up. + name: "known-values", + apply: (text, context) => { + let out = text; + const values = [...new Set(context.knownValues)] + .map((v) => v.trim()) + .filter((v) => v.length >= KNOWN_VALUE_MIN_LENGTH) + // Longest first, so an email is replaced before the username in it. + .sort((a, b) => b.length - a.length); + for (const value of values) { + out = out.replace(new RegExp(escapeRegExp(value), "gi"), "[account]"); + } + return out; + }, + }, + { + // Bearer tokens, API keys, path and query secrets: the shared scrubber. + name: "secrets", + apply: (text) => redactSecrets(text), + }, + { + name: "urls", + apply: (text) => + text.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s)>\]]+/gi, "[url]"), + }, + { + name: "emails", + apply: (text) => + text.replace( + /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, + "[email]", + ), + }, + { + // UUIDs and cuid-shaped ids: account, record and request ids. + name: "ids", + apply: (text) => + text + .replace( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, + "[id]", + ) + .replace(/\bc[a-z0-9]{20,32}\b/g, "[id]"), + }, + { + name: "ip-addresses", + apply: (text) => + text + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]") + // Four groups or more, so a clock time (`10:00:00`) is not one. + .replace(/\b(?:[0-9a-f]{1,4}:){3,7}[0-9a-f]{1,4}\b/gi, "[ip]") + .replace(/(?:\b[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:?)+/gi, "[ip]"), + }, + { + name: "host-names", + apply: (text) => + text.replace( + new RegExp( + `\\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+(?:${HOST_TLDS})\\b`, + "gi", + ), + "[host]", + ), + }, +]; + +/** Run every step (or the given list) over the text, in order. */ +export function redactSupportText( + text: string, + context: SupportRedactionContext, + steps: readonly SupportRedactionStep[] = SUPPORT_REDACTION_STEPS, +): string { + return steps.reduce((acc, step) => step.apply(acc, context), text); +} + +/** + * One line of free text, made safe to stand in the report: control + * characters out, whitespace folded, cut to `max`. The content itself is + * left to the redaction steps, which see the whole report. + */ +export function supportLine(value: unknown, max = 80): string { + if (typeof value !== "string" && typeof value !== "number") return ""; + const folded = String(value) + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return folded.length > max ? `${folded.slice(0, max - 1)}…` : folded; +} diff --git a/src/lib/support/report.ts b/src/lib/support/report.ts new file mode 100644 index 000000000..8dae07ce3 --- /dev/null +++ b/src/lib/support/report.ts @@ -0,0 +1,875 @@ +/** + * The operator's support report: a redacted plain-text summary of what this + * instance looks like, for pasting into a bug report. + * + * Three stages, kept apart so each can be tested on its own: + * + * 1. `collectSupportReportFacts()` reads the database, the queue and this + * process. Accounts leave it as numbers ("Account 1..n", oldest first); + * no account id, username or email is part of the facts. + * 2. `renderSupportReport()` turns the facts into text. Pure. + * 3. `redactSupportText()` runs every redaction step over that text, with + * the instance's own emails, usernames, names and ids as known values. + * + * What it never contains: an email address, an account id, a value anyone + * recorded, a token, the host name of a foreign service. Configuration is yes + * or no, never the setting itself. Errors appear as an action name and an + * error class, never as their message. + * + * The report is English on purpose: it is read by whoever answers the issue. + */ +import { getHeapStatistics } from "node:v8"; + +import { prisma } from "@/lib/db"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc"; +import { getLoggingConfig } from "@/lib/logging/config"; +import { + readLogBuffer, + PROBLEMS_BUFFER_MAX, +} from "@/lib/logging/in-memory-buffer"; +import type { WideEvent } from "@/lib/logging/types"; +import { + offhostBackupConfigured, + OFFHOST_BACKUP_QUEUE, +} from "@/lib/jobs/offhost-backup"; +import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy"; +import { RESTORE_DRILL_QUEUE } from "@/lib/jobs/restore-drill"; +import { + JOB_FAILURE_WINDOW_HOURS, + readFailingQueues, + readLastQueueRun, + type LastQueueRun, +} from "@/lib/jobs/job-failures"; +import { getWorkerStatus } from "@/lib/jobs/worker-status"; +import { getProcessType, shouldRunWorker } from "@/lib/process-type"; +import { offlineGeoReady } from "@/lib/geo"; +import { configuredNotificationPrivateOrigins } from "@/lib/notifications/egress-policy"; +import { isEmailConfigured } from "@/lib/notifications/senders/email-config"; +import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; +import { CONNECTION_KEYS } from "@/lib/integrations/connection-health"; +import { + redactSupportText, + supportLine, + type SupportRedactionContext, +} from "./redact"; +import packageJson from "../../../package.json"; + +/** Accounts whose connections the report reads; the rest are counted only. */ +export const SUPPORT_REPORT_ACCOUNT_CAP = 200; +/** How far back the notification tallies reach. */ +export const SUPPORT_REPORT_NOTIFICATION_DAYS = 7; +/** How many error actions the report lists. */ +export const SUPPORT_REPORT_TOP_ERRORS = 20; + +// ─── Facts ─────────────────────────────────────────────────────────────── + +export interface QueueRunFact { + at: string; + state: LastQueueRun["state"]; + errorClass: string | null; +} + +export interface SupportReportFacts { + generatedAt: string; + build: { + version: string; + buildSha: string | null; + builtAt: string | null; + node: string; + processType: string; + heapLimitMb: number; + uptimeSeconds: number; + }; + database: { + version: string | null; + timezone: string | null; + sizeBytes: number | null; + lastMigration: string | null; + lastMigrationAt: string | null; + unfinishedMigrations: number | null; + }; + /** Label and a yes / no / count answer. Never the setting itself. */ + config: Array<[string, string]>; + jobs: { + workerInThisProcess: boolean; + workerRunning: boolean; + workerLastHeartbeat: string | null; + newestFinishedJobAt: string | null; + /** `null` when the queue tables could not be read. */ + failing: Array<{ + queue: string; + failures: number; + lastFailedAt: string; + errorClass: string; + }> | null; + }; + accounts: { total: number; read: number }; + connections: Array<{ + provider: string; + state: string; + reason: string; + accounts: number[]; + }>; + incidents: Array<{ + provider: string; + reason: string; + openedAt: string | null; + announced: boolean; + account: number | null; + }>; + ai: { + defaultLevel: string | null; + maxLevel: string | null; + /** Level (or `instance default`) to number of accounts. */ + levels: Array<[string, number]>; + providers: Array<{ + provider: string; + ok: number; + failing: number; + authFailed: number; + paused: number; + lastFailureAt: string | null; + lastStatus: number | null; + }>; + }; + notifications: Array<{ + channel: string; + ok: number; + error: number; + skipped: number; + topReasons: Array<[string, number]>; + }>; + backups: { + newestLocalAt: string | null; + localCopies: number; + lastLocalRun: QueueRunFact | null; + offhostConfigured: boolean; + offhostNewestAt: string | null; + offhostOldestAt: string | null; + offhostAccounts: number; + offhostNeverStored: number; + lastOffhostRun: QueueRunFact | null; + lastRestoreDrill: QueueRunFact | null; + }; + errors: { + ringSize: number; + ringMax: number; + top: Array<{ + action: string; + level: string; + count: number; + lastSeen: string; + errorClass: string; + }>; + }; +} + +// ─── Error classes ─────────────────────────────────────────────────────── + +/** + * A short class for a failure text: never the message. Timeouts and lost + * heartbeats by name, otherwise the thrown class (`TypeError`), a Prisma code + * (`P2002`), a system code (`ECONNREFUSED`) or an HTTP status, whichever the + * text names; plain `Error` when it names none. + */ +export function errorClassOf(text: string | null | undefined): string { + if (!text) return "unknown"; + const trimmed = text.trim(); + if (/^(job timed out|handler execution exceeded)/i.test(trimmed)) { + return "timeout"; + } + if (/^job heartbeat timeout/i.test(trimmed)) return "heartbeat_timeout"; + const parts: string[] = []; + const named = /^([A-Z][A-Za-z0-9]{1,60}(?:Error|Exception))\b/.exec(trimmed); + if (named) parts.push(named[1]); + const code = + /\b(P\d{4})\b/.exec(trimmed) ?? + /\b(E[A-Z][A-Z_]{2,30})\b/.exec(trimmed) ?? + /\b(?:HTTP|status)\s*:?\s*([1-5]\d{2})\b/i.exec(trimmed); + if (code) parts.push(code[1]); + return parts.length > 0 ? parts.join(" ") : "Error"; +} + +/** The class of a wide event's failure: thrown class and code, else its level. */ +export function eventErrorClass(event: WideEvent): string { + if (event.error) { + const type = supportLine(event.error.type, 60) || "Error"; + const code = + event.error.code !== undefined && event.error.code !== null + ? supportLine(String(event.error.code), 40) + : ""; + return code ? `${type} ${code}` : type; + } + const status = event.http?.status; + return typeof status === "number" && status >= 400 ? `HTTP ${status}` : "-"; +} + +/** The name an event is grouped under: its action, else its kind and status. */ +function eventActionName(event: WideEvent): string { + const name = supportLine(event.action?.name, 80); + if (name) return name; + if (event.kind === "background") { + return supportLine(event.background?.task_name, 80) || "background"; + } + const status = event.http?.status; + return `http ${event.http?.method ?? ""} ${status ?? ""}` + .replace(/\s+/g, " ") + .trim(); +} + +/** The most frequent warn/error actions in the problems ring. */ +export function summariseProblemEvents( + events: readonly WideEvent[], + limit: number = SUPPORT_REPORT_TOP_ERRORS, +): SupportReportFacts["errors"]["top"] { + const groups = new Map< + string, + { + action: string; + level: string; + count: number; + lastSeen: string; + errorClass: string; + } + >(); + for (const event of events) { + const action = eventActionName(event); + const key = `${event.level}\u0000${action}`; + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + action, + level: event.level, + count: 1, + lastSeen: event.timestamp, + errorClass: eventErrorClass(event), + }); + continue; + } + existing.count += 1; + if (event.timestamp > existing.lastSeen) { + existing.lastSeen = event.timestamp; + existing.errorClass = eventErrorClass(event); + } + } + return [...groups.values()] + .sort((a, b) => b.count - a.count || b.lastSeen.localeCompare(a.lastSeen)) + .slice(0, limit); +} + +// ─── Collect ───────────────────────────────────────────────────────────── + +function iso(value: Date | null | undefined): string | null { + return value ? value.toISOString() : null; +} + +function runFact(run: LastQueueRun | null): QueueRunFact | null { + if (!run) return null; + return { + at: run.at, + state: run.state, + errorClass: run.error ? errorClassOf(run.error) : null, + }; +} + +function yesNo(value: boolean): string { + return value ? "yes" : "no"; +} + +function envSet(name: string): boolean { + return (process.env[name] ?? "").trim().length > 0; +} + +async function readDatabaseFacts(): Promise { + const out: SupportReportFacts["database"] = { + version: null, + timezone: null, + sizeBytes: null, + lastMigration: null, + lastMigrationAt: null, + unfinishedMigrations: null, + }; + try { + const [row] = await prisma.$queryRaw< + Array<{ version: string; timezone: string; size: bigint }> + >` + SELECT current_setting('server_version') AS version, + current_setting('TimeZone') AS timezone, + pg_database_size(current_database()) AS size + `; + if (row) { + out.version = row.version; + out.timezone = row.timezone; + out.sizeBytes = Number(row.size); + } + } catch { + /* reported as unknown */ + } + try { + const [last] = await prisma.$queryRaw< + Array<{ migration_name: string; finished_at: Date | null }> + >` + SELECT migration_name, finished_at + FROM _prisma_migrations + WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL + ORDER BY finished_at DESC, migration_name DESC + LIMIT 1 + `; + const [unfinished] = await prisma.$queryRaw>` + SELECT count(*)::int AS count + FROM _prisma_migrations + WHERE finished_at IS NULL AND rolled_back_at IS NULL + `; + out.lastMigration = last?.migration_name ?? null; + out.lastMigrationAt = iso(last?.finished_at); + out.unfinishedMigrations = unfinished?.count ?? null; + } catch { + /* no migrations table: reported as unknown */ + } + return out; +} + +async function readNewestFinishedJobAt(): Promise { + try { + const [row] = await prisma.$queryRaw>` + SELECT max(completed_on) AS at FROM pgboss.job WHERE state = 'completed' + `; + return iso(row?.at); + } catch { + return null; + } +} + +/** Read everything the report shows. Accounts leave here as numbers only. */ +export async function collectSupportReportFacts( + now: Date = new Date(), +): Promise<{ facts: SupportReportFacts; context: SupportRedactionContext }> { + const users = await prisma.user.findMany({ + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + id: true, + username: true, + email: true, + fullName: true, + aiUsageLevel: true, + }, + }); + const accountOf = new Map(); + users.forEach((u, i) => accountOf.set(u.id, i + 1)); + const knownValues: string[] = []; + for (const u of users) { + knownValues.push(u.id, u.username); + if (u.email) knownValues.push(u.email); + if (u.fullName) knownValues.push(u.fullName); + } + + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + glitchtipEnabled: true, + glitchtipDsn: true, + webPushVapidPublicKey: true, + webPushVapidPrivateKeyEncrypted: true, + adminAiKeyEncrypted: true, + adminCodexConnectionStatus: true, + aiUsageDefaultLevel: true, + aiUsageMaxLevel: true, + }, + }); + + const providerTypes = await prisma.user.groupBy({ + by: ["aiProvider"], + _count: { _all: true }, + }); + + const config: Array<[string, string]> = [ + ["Secure cookie flag", yesNo(shouldEmitSecureCookie())], + ["OIDC sign-in", yesNo(isOidcConfigured())], + ["OIDC only", yesNo(isOidcOnly())], + ["Loki", yesNo(Boolean(getLoggingConfig().lokiEndpoint))], + [ + "GlitchTip", + yesNo(Boolean(appSettings?.glitchtipEnabled && appSettings.glitchtipDsn)), + ], + ["Off-host backup", yesNo(offhostBackupConfigured())], + [ + "APNs", + yesNo( + envSet("APNS_KEY_ID") && + envSet("APNS_TEAM_ID") && + envSet("APNS_BUNDLE_ID") && + (envSet("APNS_KEY_B64") || + envSet("APNS_KEY") || + envSet("APNS_KEY_FILE")), + ), + ], + [ + "Web Push (VAPID)", + yesNo( + Boolean( + appSettings?.webPushVapidPublicKey && + appSettings.webPushVapidPrivateKeyEncrypted, + ) || + (envSet("VAPID_PUBLIC_KEY") && envSet("VAPID_PRIVATE_KEY")), + ), + ], + ["SMTP", yesNo(isEmailConfigured())], + ["GeoLite2 databases", yesNo(offlineGeoReady())], + [ + "NOTIFICATION_PRIVATE_ORIGINS entries", + String(configuredNotificationPrivateOrigins().size), + ], + ["Operator AI key", yesNo(Boolean(appSettings?.adminAiKeyEncrypted))], + [ + "Central Codex connected", + yesNo(appSettings?.adminCodexConnectionStatus === "connected"), + ], + [ + "AI provider types chosen by accounts", + providerTypes + .filter((row) => row.aiProvider) + .map((row) => `${supportLine(row.aiProvider, 30)} ${row._count._all}`) + .sort() + .join(", ") || "none", + ], + ["Demo mode", yesNo(process.env.DEMO_MODE === "true")], + ]; + + const workerStatus = getWorkerStatus(); + const [database, failing, newestFinishedJobAt] = await Promise.all([ + readDatabaseFacts(), + readFailingQueues(JOB_FAILURE_WINDOW_HOURS), + readNewestFinishedJobAt(), + ]); + + // Connections: one health read per account, capped. + const readUsers = users.slice(0, SUPPORT_REPORT_ACCOUNT_CAP); + const connectionGroups = new Map< + string, + { provider: string; state: string; reason: string; accounts: number[] } + >(); + for (const user of readUsers) { + let health; + try { + health = await loadConnectionHealth(user.id); + } catch { + continue; + } + for (const key of CONNECTION_KEYS) { + const h = health[key]; + if (!h) continue; + const groupKey = `${key}|${h.state}|${h.reason}`; + const group = connectionGroups.get(groupKey) ?? { + provider: key, + state: h.state, + reason: h.reason, + accounts: [], + }; + group.accounts.push(accountOf.get(user.id) ?? 0); + connectionGroups.set(groupKey, group); + } + } + + const incidentRows = await prisma.integrationWatch.findMany({ + where: { incidentReason: { not: null } }, + select: { + userId: true, + integration: true, + incidentReason: true, + incidentOpenedAt: true, + alertedAt: true, + }, + orderBy: { incidentOpenedAt: "desc" }, + take: 100, + }); + + const providerRows = await prisma.providerHealth.findMany({ + select: { + providerType: true, + lastResult: true, + lastStatus: true, + lastFailureAt: true, + nextRetryAt: true, + }, + }); + const providers = new Map< + string, + SupportReportFacts["ai"]["providers"][number] + >(); + for (const row of providerRows) { + const p = providers.get(row.providerType) ?? { + provider: row.providerType, + ok: 0, + failing: 0, + authFailed: 0, + paused: 0, + lastFailureAt: null, + lastStatus: null, + }; + if (row.lastResult === "ok") p.ok += 1; + else if (row.lastResult === "auth_failed") p.authFailed += 1; + else p.failing += 1; + if (row.nextRetryAt && row.nextRetryAt.getTime() > now.getTime()) { + p.paused += 1; + } + const failedAt = iso(row.lastFailureAt); + if (failedAt && (!p.lastFailureAt || failedAt > p.lastFailureAt)) { + p.lastFailureAt = failedAt; + p.lastStatus = row.lastStatus; + } + providers.set(row.providerType, p); + } + + const levelCounts = new Map(); + for (const u of users) { + const level = u.aiUsageLevel ?? "instance default"; + levelCounts.set(level, (levelCounts.get(level) ?? 0) + 1); + } + + const since = new Date( + now.getTime() - SUPPORT_REPORT_NOTIFICATION_DAYS * 24 * 60 * 60 * 1000, + ); + const [attemptCounts, reasonCounts] = await Promise.all([ + prisma.pushAttempt.groupBy({ + by: ["channel", "result"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + prisma.pushAttempt.groupBy({ + by: ["channel", "reason"], + where: { createdAt: { gte: since }, result: { not: "ok" } }, + _count: { _all: true }, + }), + ]); + const channels = new Map< + string, + SupportReportFacts["notifications"][number] + >(); + const channelOf = (channel: string) => { + const c = channels.get(channel) ?? { + channel, + ok: 0, + error: 0, + skipped: 0, + topReasons: [], + }; + channels.set(channel, c); + return c; + }; + for (const row of attemptCounts) { + const c = channelOf(row.channel); + if (row.result === "ok") c.ok += row._count._all; + else if (row.result === "error") c.error += row._count._all; + else c.skipped += row._count._all; + } + for (const row of reasonCounts) { + channelOf(row.channel).topReasons.push([ + supportLine(row.reason ?? "unspecified", 60), + row._count._all, + ]); + } + for (const c of channels.values()) { + c.topReasons = c.topReasons.sort((a, b) => b[1] - a[1]).slice(0, 3); + } + + const [ + localAggregate, + localCopies, + offhostRows, + lastLocalRun, + lastOffhostRun, + lastDrill, + ] = await Promise.all([ + prisma.dataBackup.aggregate({ _max: { createdAt: true } }), + prisma.dataBackup.count(), + prisma.offhostBackupState.findMany({ select: { lastSuccessAt: true } }), + readLastQueueRun(DATA_BACKUP_QUEUE), + readLastQueueRun(OFFHOST_BACKUP_QUEUE), + readLastQueueRun(RESTORE_DRILL_QUEUE), + ]); + const offhostDates = offhostRows + .map((r) => r.lastSuccessAt) + .filter((d): d is Date => d !== null) + .map((d) => d.toISOString()) + .sort(); + + const problemEvents = readLogBuffer({ ring: "problems" }); + + const facts: SupportReportFacts = { + generatedAt: now.toISOString(), + build: { + version: + process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version, + buildSha: process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null, + builtAt: process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null, + node: process.version, + processType: getProcessType(), + heapLimitMb: Math.round( + getHeapStatistics().heap_size_limit / 1024 / 1024, + ), + uptimeSeconds: Math.round(process.uptime()), + }, + database, + config, + jobs: { + workerInThisProcess: shouldRunWorker(), + workerRunning: workerStatus.running, + workerLastHeartbeat: workerStatus.lastHeartbeat, + newestFinishedJobAt, + failing: + failing === null + ? null + : failing.map((q) => ({ + queue: q.queue, + failures: q.failures, + lastFailedAt: q.lastFailedAt, + errorClass: errorClassOf(q.lastError), + })), + }, + accounts: { total: users.length, read: readUsers.length }, + connections: [...connectionGroups.values()].sort( + (a, b) => + a.provider.localeCompare(b.provider) || + a.state.localeCompare(b.state) || + a.reason.localeCompare(b.reason), + ), + incidents: incidentRows.map((row) => ({ + provider: row.integration, + reason: row.incidentReason ?? "", + openedAt: iso(row.incidentOpenedAt), + announced: row.alertedAt !== null, + account: accountOf.get(row.userId) ?? null, + })), + ai: { + defaultLevel: appSettings?.aiUsageDefaultLevel ?? null, + maxLevel: appSettings?.aiUsageMaxLevel ?? null, + levels: [...levelCounts.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + ), + providers: [...providers.values()].sort((a, b) => + a.provider.localeCompare(b.provider), + ), + }, + notifications: [...channels.values()].sort((a, b) => + a.channel.localeCompare(b.channel), + ), + backups: { + newestLocalAt: iso(localAggregate._max.createdAt), + localCopies, + lastLocalRun: runFact(lastLocalRun), + offhostConfigured: offhostBackupConfigured(), + offhostNewestAt: offhostDates.at(-1) ?? null, + offhostOldestAt: offhostDates[0] ?? null, + offhostAccounts: offhostRows.length, + offhostNeverStored: offhostRows.length - offhostDates.length, + lastOffhostRun: runFact(lastOffhostRun), + lastRestoreDrill: runFact(lastDrill), + }, + errors: { + ringSize: problemEvents.length, + ringMax: PROBLEMS_BUFFER_MAX, + top: summariseProblemEvents(problemEvents), + }, + }; + + return { facts, context: { knownValues } }; +} + +// ─── Render ────────────────────────────────────────────────────────────── + +function or(value: string | number | null | undefined, fallback = "unknown") { + return value === null || value === undefined || value === "" + ? fallback + : String(value); +} + +function megabytes(bytes: number | null): string { + return bytes === null ? "unknown" : `${Math.round(bytes / 1024 / 1024)} MB`; +} + +function runLine(run: QueueRunFact | null): string { + if (!run) return "no run on record"; + return run.errorClass + ? `${run.state} at ${run.at} (${run.errorClass})` + : `${run.state} at ${run.at}`; +} + +function accountList(accounts: readonly number[]): string { + const sorted = [...accounts].sort((a, b) => a - b); + const shown = sorted.slice(0, 10).map((n) => `Account ${n}`); + const more = sorted.length - shown.length; + return more > 0 ? `${shown.join(", ")} and ${more} more` : shown.join(", "); +} + +/** The report as plain text, before redaction. */ +export function renderSupportReport(facts: SupportReportFacts): string { + const lines: string[] = []; + const section = (title: string) => { + if (lines.length > 0) lines.push(""); + lines.push(`## ${title}`); + }; + const row = (label: string, value: string | number | null) => + lines.push(`- ${label}: ${or(value)}`); + + lines.push("# HealthLog support report"); + lines.push(`Generated: ${facts.generatedAt}`); + + section("Build"); + row("Version", facts.build.version); + row("Build SHA", facts.build.buildSha); + row("Built at", facts.build.builtAt); + row("Node", facts.build.node); + row("Process type", facts.build.processType); + row("Heap limit", `${facts.build.heapLimitMb} MB`); + row( + "Uptime", + `${Math.floor(facts.build.uptimeSeconds / 3600)} h ${Math.floor((facts.build.uptimeSeconds % 3600) / 60)} min`, + ); + + section("Database"); + row("PostgreSQL", facts.database.version); + row("Server time zone", facts.database.timezone); + row("Size", megabytes(facts.database.sizeBytes)); + row( + "Last migration", + facts.database.lastMigration + ? `${facts.database.lastMigration} (${or(facts.database.lastMigrationAt)})` + : null, + ); + row("Unfinished migrations", facts.database.unfinishedMigrations); + + section("Configuration"); + for (const [label, value] of facts.config) row(label, value); + + section("Jobs"); + row("Worker in this process", yesNo(facts.jobs.workerInThisProcess)); + if (facts.jobs.workerInThisProcess) { + row("Worker running", yesNo(facts.jobs.workerRunning)); + row("Worker last heartbeat", facts.jobs.workerLastHeartbeat); + } + row("Newest finished job", facts.jobs.newestFinishedJobAt); + if (facts.jobs.failing === null) { + row( + `Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h)`, + "queue tables not readable", + ); + } else if (facts.jobs.failing.length === 0) { + row(`Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h)`, "none"); + } else { + lines.push(`- Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h):`); + for (const q of facts.jobs.failing) { + lines.push( + ` - ${supportLine(q.queue, 60)}: ${q.failures} failed, last ${q.lastFailedAt} (${q.errorClass})`, + ); + } + } + + section("Connections"); + row( + "Accounts", + facts.accounts.read < facts.accounts.total + ? `${facts.accounts.total} (connections read for the oldest ${facts.accounts.read})` + : facts.accounts.total, + ); + if (facts.connections.length === 0) { + row("Connected sources", "none"); + } else { + for (const c of facts.connections) { + const who = c.state === "ok" ? "" : ` (${accountList(c.accounts)})`; + lines.push( + `- ${c.provider}: ${c.state} / ${c.reason}: ${c.accounts.length}${who}`, + ); + } + } + if (facts.incidents.length === 0) { + row("Open watchdog incidents", "none"); + } else { + lines.push("- Open watchdog incidents:"); + for (const i of facts.incidents) { + lines.push( + ` - ${supportLine(i.provider, 40)}: ${supportLine(i.reason, 40)} since ${or(i.openedAt)}, ${i.announced ? "announced" : "not announced"}${i.account ? `, Account ${i.account}` : ""}`, + ); + } + } + + section("AI"); + row("Usage level default", facts.ai.defaultLevel); + row("Usage level ceiling", facts.ai.maxLevel); + row( + "Accounts by usage level", + facts.ai.levels.map(([level, n]) => `${level} ${n}`).join(", ") || "none", + ); + if (facts.ai.providers.length === 0) { + row("Provider ledger", "empty"); + } else { + lines.push("- Provider ledger (accounts):"); + for (const p of facts.ai.providers) { + const last = p.lastFailureAt + ? `, last failure ${p.lastFailureAt}${p.lastStatus ? ` (HTTP ${p.lastStatus})` : ""}` + : ""; + lines.push( + ` - ${supportLine(p.provider, 30)}: ok ${p.ok}, failing ${p.failing}, credential refused ${p.authFailed}, paused ${p.paused}${last}`, + ); + } + } + + section(`Notifications (${SUPPORT_REPORT_NOTIFICATION_DAYS} days)`); + if (facts.notifications.length === 0) { + row("Delivery attempts", "none"); + } else { + for (const c of facts.notifications) { + const reasons = + c.topReasons.length > 0 + ? `; ${c.topReasons.map(([r, n]) => `${r} ${n}`).join(", ")}` + : ""; + lines.push( + `- ${supportLine(c.channel, 20)}: ok ${c.ok}, failed ${c.error}, skipped ${c.skipped}${reasons}`, + ); + } + } + + section("Backups"); + row("Newest in-database copy", facts.backups.newestLocalAt ?? "none"); + row("In-database copies", facts.backups.localCopies); + row("Last weekly run", runLine(facts.backups.lastLocalRun)); + row("Off-host configured", yesNo(facts.backups.offhostConfigured)); + if (facts.backups.offhostAccounts > 0) { + row("Off-host newest", facts.backups.offhostNewestAt ?? "none"); + row( + "Off-host oldest account copy", + facts.backups.offhostOldestAt ?? "none", + ); + row( + "Off-host accounts never stored", + `${facts.backups.offhostNeverStored} of ${facts.backups.offhostAccounts}`, + ); + } + row("Last off-host run", runLine(facts.backups.lastOffhostRun)); + row("Last restore drill", runLine(facts.backups.lastRestoreDrill)); + + section("Recent warnings and errors (this process)"); + row( + "Problems ring", + `${facts.errors.ringSize} of ${facts.errors.ringMax} entries`, + ); + if (facts.errors.top.length === 0) { + row("Top actions", "none"); + } else { + for (const e of facts.errors.top) { + lines.push( + `- ${e.level} ${e.action}: ${e.count}x, last ${e.lastSeen}, ${e.errorClass}`, + ); + } + } + + return `${lines.join("\n")}\n`; +} + +/** Facts to finished, redacted text. */ +export function buildSupportReportText( + facts: SupportReportFacts, + context: SupportRedactionContext, +): string { + return redactSupportText(renderSupportReport(facts), context); +} diff --git a/tests/integration/admin-support-report.test.ts b/tests/integration/admin-support-report.test.ts new file mode 100644 index 000000000..8a05ac7a5 --- /dev/null +++ b/tests/integration/admin-support-report.test.ts @@ -0,0 +1,152 @@ +/** + * `GET /api/admin/support-report` against real Postgres. + * + * - admin only, through the real `apiHandler` and `requireAdmin`; + * - the raw reads (server version, time zone, size, the migrations table, + * the queue) and the group-bys run against the real schema; + * - accounts with distinctive usernames, emails and names appear only as + * "Account n", and their ids never. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + 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), +})); + +import { GET } from "@/app/api/admin/support-report/route"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function call() { + return GET(new NextRequest("http://localhost/api/admin/support-report")); +} + +describe("GET /api/admin/support-report (integration)", () => { + it("refuses a signed-in non-admin", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username: "plainmember", email: "plain.member@example.test" }, + }); + await signIn(user.id); + const res = await call(); + expect(res.status).toBe(403); + }); + + it("answers a redacted report built from the real schema", async () => { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "operatorwithaname", + email: "operator.distinct@example.test", + fullName: "Ottilie Operatorson", + role: "ADMIN", + }, + }); + const member = await prisma.user.create({ + data: { + username: "memberwithaname", + email: "member.distinct@example.test", + fullName: "Mathilda Memberson", + }, + }); + await prisma.providerHealth.create({ + data: { + userId: member.id, + providerType: "LOCAL", + lastResult: "auth_failed", + lastStatus: 401, + consecutiveFailures: 3, + lastFailureAt: new Date(), + nextRetryAt: new Date(Date.now() + 3_600_000), + }, + }); + await prisma.integrationWatch.create({ + data: { + userId: member.id, + integration: "nightscout", + incidentReason: "unexpected_silence", + incidentOpenedAt: new Date(), + }, + }); + await prisma.pushAttempt.create({ + data: { + userId: member.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "error", + reason: "http_500", + }, + }); + await signIn(admin.id); + + const res = await call(); + expect(res.status).toBe(200); + const body = await res.json(); + const text: string = body.data.text; + + expect(text).toMatch(/- PostgreSQL: \d+/); + expect(text).toMatch(/- Server time zone: \S+/); + expect(text).toMatch(/- Size: \d+ MB/); + expect(text).toMatch(/- Last migration: \d{4}_\w+ \(/); + expect(text).toContain("- Unfinished migrations: 0"); + expect(text).toContain( + "- LOCAL: ok 0, failing 0, credential refused 1, paused 1", + ); + expect(text).toContain("nightscout: unexpected_silence since"); + expect(text).toContain("Account 2"); + expect(text).toContain("- NTFY: ok 0, failed 1, skipped 0; http_500 1"); + expect(text).toContain("- Accounts: 2"); + + for (const value of [ + admin.id, + member.id, + admin.username, + member.username, + admin.email!, + member.email!, + "Ottilie", + "Mathilda", + ]) { + expect(text.toLowerCase(), value).not.toContain(value.toLowerCase()); + } + }); +});