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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:07:44 +02:00
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);
});
});