mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
chore(release): v1.4.27
v1.4.27 — Mobile capability and maintainer-finding cleanup. ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop) applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer (graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line release notes attached to the GitHub Release.
This commit is contained in:
1 parent
d98e85cd03
commit
4f09c3f862
314 files changed
+26801
-4419
No files matched your search
@@ -8,10 +8,51 @@
|
||||
*
|
||||
* The formatter is a pure function so we can test deterministically
|
||||
* without touching Prisma or the dispatcher.
|
||||
*
|
||||
* v1.4.27 — additionally asserts `parkIntegrationAtReauth` semantics:
|
||||
* the helper writes the row but neither increments the failure counter
|
||||
* nor pages admins through the dispatcher. The two scope-skip
|
||||
* call-sites in `withings/sync-{activity,sleep}.ts` must use this
|
||||
* helper rather than `recordSyncFailure`.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { formatAdminAlertPayload } from "../status";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
integrationStatus: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/dispatcher", () => ({
|
||||
dispatchNotification: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc(${s})`,
|
||||
decrypt: (s: string) =>
|
||||
s.startsWith("enc(") && s.endsWith(")") ? s.slice(4, -1) : s,
|
||||
}));
|
||||
|
||||
import { formatAdminAlertPayload, parkIntegrationAtReauth } from "../status";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("formatAdminAlertPayload — Withings re-auth", () => {
|
||||
it("uses the integration display name (Withings, not 'withings')", () => {
|
||||
@@ -148,3 +189,109 @@ describe("formatAdminAlertPayload — omits errorCode parens when undefined", ()
|
||||
expect(out.message).toContain("transient error — network reset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", () => {
|
||||
it("sets state=error_reauth without incrementing consecutiveFailures and without paging admins", async () => {
|
||||
// Pre-existing row at counter=87 — the maintainer's reported
|
||||
// surviving-deploy state. The first scope-skip after the v1.4.27
|
||||
// deploy must NOT push the row to 88 and must NOT trigger the
|
||||
// 3-strike alert ladder.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
state: "error_transient",
|
||||
lastError: "enc(old)",
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await parkIntegrationAtReauth({
|
||||
userId: "u1",
|
||||
integration: "withings",
|
||||
message: "Withings connection is missing the user.activity scope.",
|
||||
errorCode: "scope_missing",
|
||||
});
|
||||
|
||||
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
|
||||
.calls[0][0];
|
||||
expect(upsertArgs.update).toMatchObject({
|
||||
state: "error_reauth",
|
||||
lastError: "enc(Withings connection is missing the user.activity scope.)",
|
||||
});
|
||||
// The whole point of the helper: counter is not in the update set.
|
||||
expect(upsertArgs.update).not.toHaveProperty("consecutiveFailures");
|
||||
|
||||
// No admin alert fired.
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
|
||||
// A standalone reauth audit row IS written so the ops trail still
|
||||
// shows the park event — but it lands on
|
||||
// `integrations.reauth_required`, not `integrations.sync.failed`.
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"integrations.reauth_required",
|
||||
expect.objectContaining({
|
||||
userId: "u1",
|
||||
details: expect.objectContaining({
|
||||
integration: "withings",
|
||||
errorCode: "scope_missing",
|
||||
source: "scope_skip",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(auditLog).not.toHaveBeenCalledWith(
|
||||
"integrations.sync.failed",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent on the audit log — a second call for the same scope-skip writes no extra audit row", async () => {
|
||||
// First call: row already parked with the same lastError. The
|
||||
// helper detects "no fresh park" and skips the audit write.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
state: "error_reauth",
|
||||
lastError: "enc(Withings connection is missing the user.activity scope.)",
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await parkIntegrationAtReauth({
|
||||
userId: "u1",
|
||||
integration: "withings",
|
||||
message: "Withings connection is missing the user.activity scope.",
|
||||
errorCode: "scope_missing",
|
||||
});
|
||||
|
||||
// Upsert still runs (it's the "set lastAttemptAt" touch), but the
|
||||
// audit log is unchanged.
|
||||
expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a fresh row at counter=0 when no row exists yet", async () => {
|
||||
// First-ever sync attempt on a legacy connection: no row in the
|
||||
// table. Helper must create the row at counter=0 so a later
|
||||
// genuine transient burst still has the full 3-strike runway.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await parkIntegrationAtReauth({
|
||||
userId: "u-new",
|
||||
integration: "withings",
|
||||
message: "scope missing",
|
||||
errorCode: "scope_missing",
|
||||
});
|
||||
|
||||
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
|
||||
.calls[0][0];
|
||||
expect(upsertArgs.create).toMatchObject({
|
||||
userId: "u-new",
|
||||
integration: "withings",
|
||||
state: "error_reauth",
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -270,6 +270,80 @@ export async function recordSyncFailure(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a connection at `error_reauth` from a deliberate scope-skip
|
||||
* short-circuit. Unlike `recordSyncFailure`, this helper:
|
||||
*
|
||||
* 1. does NOT increment `consecutiveFailures`.
|
||||
* 2. does NOT write an `integrations.sync.failed` audit row through
|
||||
* `recordSyncFailure`. A standalone `integrations.reauth_required`
|
||||
* row is written instead so the operations trail still shows the
|
||||
* park event.
|
||||
* 3. does NOT enter the 3-strike alert ladder — no admin Telegram
|
||||
* page fires.
|
||||
*
|
||||
* Idempotent: a second call for the same scope-skip leaves the row at
|
||||
* `error_reauth` with the same encrypted message and the same counter
|
||||
* value (no increment). Use it from sync routines that have detected a
|
||||
* deliberate, structural scope gap (e.g. legacy Withings connection
|
||||
* missing `user.activity`). The defence-in-depth catch-block path
|
||||
* stays on `recordSyncFailure` because a 403 reaching the catch is
|
||||
* genuinely unexpected once the scope-skip lands.
|
||||
*
|
||||
* The audit-row-once semantics mean a row is only written if the row
|
||||
* is not already parked at `error_reauth` with the same `lastError`.
|
||||
* Re-parking the same scope-skip is a no-op for the audit log.
|
||||
*/
|
||||
export async function parkIntegrationAtReauth(opts: {
|
||||
userId: string;
|
||||
integration: IntegrationKey;
|
||||
message: string;
|
||||
errorCode: string;
|
||||
}): Promise<void> {
|
||||
const { userId, integration, message, errorCode } = opts;
|
||||
const now = new Date();
|
||||
const encryptedError = safeEncryptError(message);
|
||||
|
||||
// Idempotency probe: re-parking the same scope-skip should NOT emit
|
||||
// another audit row. We read the current row first; only write the
|
||||
// audit log when the state or error changes.
|
||||
const existing = await prisma.integrationStatus.findUnique({
|
||||
where: { userId_integration: { userId, integration } },
|
||||
select: { state: true, lastError: true },
|
||||
});
|
||||
const isFreshPark =
|
||||
existing?.state !== "error_reauth" || existing.lastError !== encryptedError;
|
||||
|
||||
await prisma.integrationStatus.upsert({
|
||||
where: { userId_integration: { userId, integration } },
|
||||
create: {
|
||||
userId,
|
||||
integration,
|
||||
state: "error_reauth",
|
||||
lastError: encryptedError,
|
||||
lastAttemptAt: now,
|
||||
// First-ever row for this (user, integration) — counter stays at
|
||||
// 0 so a later genuine transient burst still has the full
|
||||
// 3-strike runway before paging.
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
update: {
|
||||
state: "error_reauth",
|
||||
lastError: encryptedError,
|
||||
lastAttemptAt: now,
|
||||
// Deliberately omit `consecutiveFailures` — the existing value
|
||||
// is preserved exactly. This is the whole point of the helper.
|
||||
},
|
||||
});
|
||||
|
||||
if (isFreshPark) {
|
||||
await auditLog("integrations.reauth_required", {
|
||||
userId,
|
||||
details: { integration, message, errorCode, source: "scope_skip" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a connection as needing re-auth without recording a fresh
|
||||
* "attempt". Used by the OAuth/refresh-token flows when they detect
|
||||
|
||||
Reference in new issue
Block a user