mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(polar): lift invalid_grant to reauth and harden the credentials surface
classifyPolarError now lifts a 400 carrying invalid_grant on the token endpoint from persistent to reauth_required, mirroring Oura via the shared isOAuthInvalidGrant helper. A stale or replayed authorization code at connect time now surfaces the reconnect prompt instead of silently parking after 24 h. The BYO-credentials DELETE now audit-logs and parks the integration ledger at disconnected for parity with the dedicated disconnect route, and the PUT audit-logs the credential write. Refresh the connect-route comment that still described Polar as env-only OAuth; it has resolved BYO-first since v1.17.1. Add the invalid_grant classification test plus the previously-missing callback (happy path, CSRF mismatch, cross-user, off-spec token), connect, and credentials route tests.
This commit is contained in:
1 parent
574b860eb4
commit
87e570abfe
7 files changed
+348
-5
No files matched your search
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PolarApiError,
|
||||
classifyPolarError,
|
||||
isInvalidGrant,
|
||||
} from "../response-classifier";
|
||||
|
||||
describe("classifyPolarError — invalid_grant reauth lift (H-1)", () => {
|
||||
it("lifts a 400 invalid_grant on the token endpoint to reauth_required", () => {
|
||||
const err = new PolarApiError({
|
||||
verb: "exchangeCode",
|
||||
classification: "persistent",
|
||||
httpStatus: 400,
|
||||
reason: "http_400",
|
||||
upstreamError: "invalid_grant",
|
||||
});
|
||||
expect(isInvalidGrant(err)).toBe(true);
|
||||
expect(classifyPolarError(err)).toBe("reauth_required");
|
||||
});
|
||||
|
||||
it("recovers the verdict from the message shape after a lost prototype", () => {
|
||||
// A pg-boss retry can strip the PolarApiError prototype; the message-shape
|
||||
// fallback must still recognise the invalid_grant signal.
|
||||
const bare = new Error("Polar exchangeCode error: 400 - invalid_grant");
|
||||
expect(isInvalidGrant(bare)).toBe(true);
|
||||
expect(classifyPolarError(bare)).toBe("reauth_required");
|
||||
});
|
||||
|
||||
it("leaves a non-invalid_grant 400 as persistent", () => {
|
||||
const err = new PolarApiError({
|
||||
verb: "exchangeCode",
|
||||
classification: "persistent",
|
||||
httpStatus: 400,
|
||||
reason: "http_400",
|
||||
upstreamError: "invalid_client",
|
||||
});
|
||||
expect(isInvalidGrant(err)).toBe(false);
|
||||
expect(classifyPolarError(err)).toBe("persistent");
|
||||
});
|
||||
|
||||
it("keeps a 401 reauth and a 500 transient classification", () => {
|
||||
expect(
|
||||
classifyPolarError(
|
||||
new PolarApiError({
|
||||
verb: "fetchSleeps",
|
||||
classification: "reauth_required",
|
||||
httpStatus: 401,
|
||||
reason: "http_401",
|
||||
}),
|
||||
),
|
||||
).toBe("reauth_required");
|
||||
expect(
|
||||
classifyPolarError(
|
||||
new PolarApiError({
|
||||
verb: "fetchSleeps",
|
||||
classification: "transient",
|
||||
httpStatus: 500,
|
||||
reason: "http_500",
|
||||
}),
|
||||
),
|
||||
).toBe("transient");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
classifyHttpStatus,
|
||||
classifyIntegrationError,
|
||||
IntegrationApiError,
|
||||
isOAuthInvalidGrant,
|
||||
type ClassifiedHttpResponse,
|
||||
type IntegrationClassification,
|
||||
} from "@/lib/integrations/http-status-classifier";
|
||||
@@ -48,6 +49,17 @@ export class PolarApiError extends IntegrationApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
|
||||
* a stale/replayed authorization code on Polar's only token call
|
||||
* (`exchangeCode`). Lifts a 400 carrying `invalid_grant` from `persistent` to
|
||||
* `reauth_required` so the connection surfaces the reconnect prompt instead of
|
||||
* silently parking after 24 h.
|
||||
*/
|
||||
export function isInvalidGrant(err: unknown): boolean {
|
||||
return isOAuthInvalidGrant(err, "Polar");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the classification from any caught error. A non-`PolarApiError` defaults
|
||||
* to `transient` so a plain `Error` retries rather than disabling the
|
||||
@@ -55,5 +67,6 @@ export class PolarApiError extends IntegrationApiError {
|
||||
* caller that lost the prototype across a pg-boss retry.
|
||||
*/
|
||||
export function classifyPolarError(err: unknown): PolarClassification {
|
||||
if (isInvalidGrant(err)) return "reauth_required";
|
||||
return classifyIntegrationError(err, "Polar");
|
||||
}
|
||||
Reference in new issue
Block a user