mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
feat(audit): sync failures logged with structured meta
v1.4.15 Phase B2, criterion 4. Every IntegrationStatus failure path
now writes a single AuditLog row with a documented schema, and this
commit lights up the real-Postgres regression guard for it:
action : "integrations.sync.failed"
userId : <subject>
details : {
integration: "withings" | "moodlog",
kind: "transient" | "reauth_required",
errorCode: string | null,
message: string, // plaintext (not encrypted —
// audit table is admin-only)
attemptNumber: number, // 1..N consecutive
state: "error_transient" | "error_reauth"
}
Successes are NOT audited — that path would be far too noisy
(every webhook-triggered sync is a row, ~ten times a day per active
user) and the integration_statuses.lastSuccessAt column already
tracks the same signal at one row per integration per user.
The integration test in tests/integration/integration-status.test.ts
provides 8 cases against a real Postgres testcontainer:
- failure writes both the integration_statuses row AND a matching
AuditLog entry, with the structured meta validated field-by-field
- lastError is encrypted at rest (column never matches plaintext)
- getIntegrationStatus() decrypts cleanly on read
- success clears the streak AND does NOT write an audit row
- admin Telegram alert fires once per burst (24h re-alert window)
- reauth_required parks the integration so isReauthRequired() is true
- markReconnected() clears the parked state without writing a fresh
success — that's the next sync's job
- markDisconnected() preserves the row as a tombstone but wipes
error state
- CASCADE delete on User removes the IntegrationStatus row
Setup file additions: integration_statuses added to the truncate-list
in tests/integration/setup.ts (already shipped under commit 4db72a8;
this commit only adds the test file that consumes it).
Tests: 11 / 39 integration pass. The dispatcher is module-mocked so
the test doesn't actually hit Telegram — we only assert that the
helper INVOKED the dispatcher with the right payload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
604dff057a
commit
2fbf56dc16
1 file changed
+260
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* v1.4.15 Phase B2 — IntegrationStatus end-to-end (real Postgres).
|
||||
*
|
||||
* The unit suite (`src/lib/integrations/__tests__/status.test.ts`)
|
||||
* mocks Prisma. This file lights up the real driver so we lock in the
|
||||
* upsert + concurrent-write story AND prove that every sync failure
|
||||
* writes one `AuditLog` row with the documented schema:
|
||||
*
|
||||
* action : "integrations.sync.failed"
|
||||
* userId : <subject>
|
||||
* details : { integration, kind, errorCode, message,
|
||||
* attemptNumber, state }
|
||||
*
|
||||
* The dispatcher is mocked at module level so we don't actually hit
|
||||
* Telegram while testing — the assertion is on the alerting side
|
||||
* effects, not the network.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Crypto reads `ENCRYPTION_KEY` lazily on first encrypt(). vitest does
|
||||
// NOT load the dev `.env`, so we seed a deterministic 32-byte test key
|
||||
// before any `@/lib/integrations/status` import — that module wraps
|
||||
// `encrypt()` for the `lastError` column.
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// The dispatcher would otherwise try to read encrypted channel
|
||||
// configs and call sendViaTelegram. We're not testing that path —
|
||||
// B3 owns it. Stub returns void so the helper's await chain
|
||||
// resolves cleanly.
|
||||
vi.mock("@/lib/notifications/dispatcher", () => ({
|
||||
dispatchNotification: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
markReauthRequired,
|
||||
markDisconnected,
|
||||
markReconnected,
|
||||
isReauthRequired,
|
||||
getIntegrationStatus,
|
||||
} from "@/lib/integrations/status";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
|
||||
const TEST_USER_ID = "user-integration-status-e2e";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "int-status-test",
|
||||
email: "int-status@example.test",
|
||||
},
|
||||
});
|
||||
vi.mocked(dispatchNotification).mockClear();
|
||||
});
|
||||
|
||||
describe("IntegrationStatus end-to-end", () => {
|
||||
it("recordSyncFailure writes an integration_statuses row AND a matching AuditLog entry", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
message: "Withings refresh error: 503 - upstream",
|
||||
errorCode: "503",
|
||||
});
|
||||
|
||||
const status = await getPrismaClient().integrationStatus.findUnique({
|
||||
where: {
|
||||
userId_integration: {
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.state).toBe("error_transient");
|
||||
expect(status!.consecutiveFailures).toBe(1);
|
||||
expect(status!.lastAttemptAt).toBeInstanceOf(Date);
|
||||
expect(status!.lastError).toBeTruthy();
|
||||
// Encrypted at rest — must NOT be the plaintext message.
|
||||
expect(status!.lastError).not.toContain("Withings refresh error");
|
||||
|
||||
const audits = await getPrismaClient().auditLog.findMany({
|
||||
where: { action: "integrations.sync.failed", userId: TEST_USER_ID },
|
||||
});
|
||||
expect(audits).toHaveLength(1);
|
||||
const detailsParsed = JSON.parse(audits[0].details ?? "{}");
|
||||
expect(detailsParsed).toMatchObject({
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
errorCode: "503",
|
||||
message: "Withings refresh error: 503 - upstream",
|
||||
attemptNumber: 1,
|
||||
state: "error_transient",
|
||||
});
|
||||
});
|
||||
|
||||
it("getIntegrationStatus returns plaintext error after roundtrip (decryption succeeds)", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "moodlog",
|
||||
kind: "transient",
|
||||
message: "moodLog sync HTTP 502",
|
||||
errorCode: "http_502",
|
||||
});
|
||||
const snapshot = await getIntegrationStatus(TEST_USER_ID, "moodlog");
|
||||
expect(snapshot.state).toBe("error_transient");
|
||||
expect(snapshot.lastError).toBe("moodLog sync HTTP 502");
|
||||
expect(snapshot.consecutiveFailures).toBe(1);
|
||||
});
|
||||
|
||||
it("recordSyncSuccess clears the streak, resets state, and (importantly) does NOT write an audit row", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
message: "transient blip",
|
||||
});
|
||||
await recordSyncSuccess(TEST_USER_ID, "withings");
|
||||
|
||||
const status = await getPrismaClient().integrationStatus.findUnique({
|
||||
where: {
|
||||
userId_integration: { userId: TEST_USER_ID, integration: "withings" },
|
||||
},
|
||||
});
|
||||
expect(status!.state).toBe("connected");
|
||||
expect(status!.consecutiveFailures).toBe(0);
|
||||
expect(status!.lastError).toBeNull();
|
||||
expect(status!.lastSuccessAt).toBeInstanceOf(Date);
|
||||
|
||||
// Successes are NOT audited (would be too noisy — design choice).
|
||||
const successAudits = await getPrismaClient().auditLog.findMany({
|
||||
where: { action: "integrations.sync.success" },
|
||||
});
|
||||
expect(successAudits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("crossing the default threshold (3) dispatches an admin Telegram alert exactly once per burst", async () => {
|
||||
// Seed an admin user so the alert resolution finds at least one
|
||||
// recipient. The dispatcher itself is stubbed.
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: "admin-int-status-test",
|
||||
username: "admin-int-status",
|
||||
email: "admin@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
|
||||
// 3 consecutive failures in the same burst.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
message: "Withings refresh error: 503 - upstream",
|
||||
errorCode: "503",
|
||||
});
|
||||
}
|
||||
|
||||
// Exactly one alert (on the 3rd failure crossing threshold).
|
||||
expect(dispatchNotification).toHaveBeenCalledTimes(1);
|
||||
const payload = vi.mocked(dispatchNotification).mock.calls[0][0];
|
||||
expect(payload.eventType).toBe("SYSTEM_ALERT");
|
||||
expect(payload.userId).toBe("admin-int-status-test");
|
||||
|
||||
// alertedAt is stamped — a 4th failure inside the 24h window must
|
||||
// NOT page again.
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
message: "Withings refresh error: 503 - upstream",
|
||||
});
|
||||
expect(dispatchNotification).toHaveBeenCalledTimes(1);
|
||||
|
||||
// After a single success, a fresh streak starts and the next
|
||||
// burst is allowed to alert again.
|
||||
await recordSyncSuccess(TEST_USER_ID, "withings");
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
message: "Withings refresh error: 503 - upstream",
|
||||
});
|
||||
}
|
||||
expect(dispatchNotification).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("kind=reauth_required parks the integration so isReauthRequired() returns true", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "moodlog",
|
||||
kind: "reauth_required",
|
||||
message: "moodLog sync HTTP 401",
|
||||
errorCode: "http_401",
|
||||
});
|
||||
expect(await isReauthRequired(TEST_USER_ID, "moodlog")).toBe(true);
|
||||
expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(false);
|
||||
});
|
||||
|
||||
it("markReconnected clears the parked state without writing a fresh success", async () => {
|
||||
await markReauthRequired(TEST_USER_ID, "withings", "invalid_grant");
|
||||
expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(true);
|
||||
|
||||
await markReconnected(TEST_USER_ID, "withings");
|
||||
expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(false);
|
||||
|
||||
const row = await getPrismaClient().integrationStatus.findUnique({
|
||||
where: {
|
||||
userId_integration: { userId: TEST_USER_ID, integration: "withings" },
|
||||
},
|
||||
});
|
||||
expect(row!.state).toBe("connected");
|
||||
// No fresh lastSuccessAt — that's the next sync's job.
|
||||
expect(row!.lastSuccessAt).toBeNull();
|
||||
});
|
||||
|
||||
it("markDisconnected wipes the error state but leaves the row as a tombstone", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "moodlog",
|
||||
kind: "transient",
|
||||
message: "blip",
|
||||
});
|
||||
await markDisconnected(TEST_USER_ID, "moodlog");
|
||||
const row = await getPrismaClient().integrationStatus.findUnique({
|
||||
where: {
|
||||
userId_integration: { userId: TEST_USER_ID, integration: "moodlog" },
|
||||
},
|
||||
});
|
||||
expect(row!.state).toBe("disconnected");
|
||||
expect(row!.lastError).toBeNull();
|
||||
expect(row!.consecutiveFailures).toBe(0);
|
||||
});
|
||||
|
||||
it("CASCADE delete on User removes the IntegrationStatus row", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
kind: "transient",
|
||||
message: "blip",
|
||||
});
|
||||
await getPrismaClient().user.delete({ where: { id: TEST_USER_ID } });
|
||||
const remaining = await getPrismaClient().integrationStatus.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user