mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const {
|
||||
stateMatchesCookieMock,
|
||||
verifySignedStateMock,
|
||||
getSessionMock,
|
||||
exchangeCodeMock,
|
||||
registerUserMock,
|
||||
getCredsMock,
|
||||
markReconnectedMock,
|
||||
} = vi.hoisted(() => ({
|
||||
stateMatchesCookieMock: vi.fn(),
|
||||
verifySignedStateMock: vi.fn(),
|
||||
getSessionMock: vi.fn(),
|
||||
exchangeCodeMock: vi.fn(),
|
||||
registerUserMock: vi.fn(),
|
||||
getCredsMock: vi.fn(),
|
||||
markReconnectedMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({ setAuth: vi.fn(), setError: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock }));
|
||||
vi.mock("@/lib/polar/client", () => ({
|
||||
exchangeCode: exchangeCodeMock,
|
||||
registerUser: registerUserMock,
|
||||
}));
|
||||
vi.mock("@/lib/polar/credentials", () => ({
|
||||
getPolarClientCredentials: getCredsMock,
|
||||
}));
|
||||
vi.mock("@/lib/oauth/signed-state", () => ({
|
||||
oauthStateCookieName: () => "polar_oauth_state",
|
||||
stateMatchesCookie: stateMatchesCookieMock,
|
||||
verifySignedState: verifySignedStateMock,
|
||||
}));
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
markReconnected: markReconnectedMock,
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
|
||||
|
||||
function makeReq(): Parameters<typeof GET>[0] {
|
||||
return {
|
||||
url: "https://app.example/api/polar/callback?code=abc&state=s",
|
||||
cookies: { get: () => ({ value: "s" }) },
|
||||
} as unknown as Parameters<typeof GET>[0];
|
||||
}
|
||||
|
||||
const run = GET as unknown as (req: ReturnType<typeof makeReq>) => Promise<{
|
||||
status: number;
|
||||
headers: { get(k: string): string | null };
|
||||
}>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stateMatchesCookieMock.mockReturnValue(true);
|
||||
verifySignedStateMock.mockReturnValue({ userId: "u1" });
|
||||
getSessionMock.mockResolvedValue(null);
|
||||
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
|
||||
exchangeCodeMock.mockResolvedValue({ access_token: "tok", x_user_id: 42 });
|
||||
registerUserMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("GET /api/polar/callback", () => {
|
||||
it("completes the handshake and redirects to connected", async () => {
|
||||
const res = await run(makeReq());
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain("polar=connected");
|
||||
expect(markReconnectedMock).toHaveBeenCalledWith("u1", "polar");
|
||||
});
|
||||
|
||||
it("rejects a CSRF cookie/param mismatch before exchanging the code", async () => {
|
||||
stateMatchesCookieMock.mockReturnValue(false);
|
||||
const res = await run(makeReq());
|
||||
expect(res.headers.get("location")).toContain("reason=csrf1");
|
||||
expect(exchangeCodeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a cross-user session completing another user's handshake", async () => {
|
||||
getSessionMock.mockResolvedValue({ user: { id: "other" } });
|
||||
const res = await run(makeReq());
|
||||
expect(res.headers.get("location")).toContain("reason=cross_user");
|
||||
expect(exchangeCodeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an off-spec token body missing x_user_id", async () => {
|
||||
exchangeCodeMock.mockResolvedValue({ access_token: "tok" });
|
||||
const res = await run(makeReq());
|
||||
expect(res.headers.get("location")).toContain("reason=token");
|
||||
expect(registerUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user