mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(admin): fill the redacted support report with its redaction guard
This commit is contained in:
1 parent
1af0de5e87
commit
b873ce8bc8
8 files changed
+1801
-5
No files matched your search
@@ -28,7 +28,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
"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<Record<string, readonly string[]>> = {
|
||||
};
|
||||
|
||||
/** 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"] })
|
||||
|
||||
@@ -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<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
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());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -29,7 +29,8 @@ function call(
|
||||
handler: () => Promise<Response>,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
return apiHandler(handler)(
|
||||
const route = apiHandler(async (_request: NextRequest) => handler());
|
||||
return route(
|
||||
new NextRequest("http://localhost/api/probe", { headers }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SupportReportFacts["database"]> {
|
||||
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<Array<{ count: number }>>`
|
||||
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<string | null> {
|
||||
try {
|
||||
const [row] = await prisma.$queryRaw<Array<{ at: Date | null }>>`
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user