diff --git a/src/app/api/strava/credentials/__tests__/route.test.ts b/src/app/api/strava/credentials/__tests__/route.test.ts index f57870da6..a2e6b53d3 100644 --- a/src/app/api/strava/credentials/__tests__/route.test.ts +++ b/src/app/api/strava/credentials/__tests__/route.test.ts @@ -27,7 +27,10 @@ const { storeMock, clearMock } = vi.hoisted(() => ({ storeMock: vi.fn(), clearMock: vi.fn(), })); -vi.mock("@/lib/strava/credentials", () => ({ +vi.mock("@/lib/strava/credentials", async (importOriginal) => ({ + STRAVA_DISCONNECTED: ( + await importOriginal() + ).STRAVA_DISCONNECTED, storeStravaClientCredentials: storeMock, clearStravaClientCredentials: clearMock, })); @@ -80,14 +83,18 @@ describe("DELETE /api/strava/credentials", () => { expect(markDisconnected).toHaveBeenCalledWith("u1", "strava"); }); - it("clears the athlete id alongside both tokens", async () => { + it("clears the athlete id and the backfill state alongside both tokens", async () => { await del(); + // The backfill markers belong to the account that was connected; a later + // connection must start its own history walk from the newest activity. expect(userUpdate).toHaveBeenCalledWith( expect.objectContaining({ data: { stravaAccessTokenEncrypted: null, stravaRefreshTokenEncrypted: null, stravaAthleteId: null, + stravaBackfillCompletedAt: null, + stravaBackfillResumeBefore: null, }, }), ); diff --git a/src/app/api/strava/credentials/route.ts b/src/app/api/strava/credentials/route.ts index 7605775ef..30607cc1a 100644 --- a/src/app/api/strava/credentials/route.ts +++ b/src/app/api/strava/credentials/route.ts @@ -12,6 +12,7 @@ import { markDisconnected } from "@/lib/integrations/status"; import { storeStravaClientCredentials, clearStravaClientCredentials, + STRAVA_DISCONNECTED, } from "@/lib/strava/credentials"; import { stravaCredentialsSchema } from "@/lib/validations/strava"; import { NextRequest } from "next/server"; @@ -98,11 +99,7 @@ export const DELETE = apiHandler(async () => { await prisma.user.update({ where: { id: user.id }, - data: { - stravaAccessTokenEncrypted: null, - stravaRefreshTokenEncrypted: null, - stravaAthleteId: null, - }, + data: STRAVA_DISCONNECTED, }); await clearStravaClientCredentials(user.id); diff --git a/src/app/api/strava/disconnect/__tests__/route.test.ts b/src/app/api/strava/disconnect/__tests__/route.test.ts new file mode 100644 index 000000000..ee03d9dd0 --- /dev/null +++ b/src/app/api/strava/disconnect/__tests__/route.test.ts @@ -0,0 +1,63 @@ +/** + * `POST /api/strava/disconnect` leaves nothing of the connected account behind + * that a later connection would inherit: not the tokens, and not the backfill + * markers. A resume point left standing made the next account's first walk + * start below the old account's oldest activity, so its newer history was + * never asked for. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); +vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } })); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => null, +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/strava/client", () => ({ deauthorize: vi.fn() })); +vi.mock("@/lib/strava/credentials", async (importOriginal) => ({ + STRAVA_DISCONNECTED: ( + await importOriginal() + ).STRAVA_DISCONNECTED, + getStravaConnection: vi.fn(async () => ({ accessToken: "tok" })), +})); +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; + +const userUpdate = prisma.user.update as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + userUpdate.mockResolvedValue({}); +}); + +describe("POST /api/strava/disconnect", () => { + it("clears the backfill markers with the tokens", async () => { + const res = (await (POST as unknown as () => Promise<{ data: unknown }>)()) + .data; + expect(res).toEqual({ disconnected: true }); + expect(userUpdate).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { + stravaAccessTokenEncrypted: null, + stravaRefreshTokenEncrypted: null, + stravaAthleteId: null, + stravaBackfillCompletedAt: null, + stravaBackfillResumeBefore: null, + }, + }); + }); +}); diff --git a/src/app/api/strava/disconnect/route.ts b/src/app/api/strava/disconnect/route.ts index b41c4a75e..e3634b330 100644 --- a/src/app/api/strava/disconnect/route.ts +++ b/src/app/api/strava/disconnect/route.ts @@ -6,7 +6,10 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { markDisconnected } from "@/lib/integrations/status"; import { deauthorize } from "@/lib/strava/client"; -import { getStravaConnection } from "@/lib/strava/credentials"; +import { + getStravaConnection, + STRAVA_DISCONNECTED, +} from "@/lib/strava/credentials"; /** * v1.28.x — disconnect Strava for the current user. @@ -42,11 +45,7 @@ export const POST = apiHandler(async () => { await prisma.user.update({ where: { id: user.id }, - data: { - stravaAccessTokenEncrypted: null, - stravaRefreshTokenEncrypted: null, - stravaAthleteId: null, - }, + data: STRAVA_DISCONNECTED, }); await auditLog("strava.disconnect", { userId: user.id }); diff --git a/src/lib/strava/credentials.ts b/src/lib/strava/credentials.ts index ff14695d6..768911c8e 100644 --- a/src/lib/strava/credentials.ts +++ b/src/lib/strava/credentials.ts @@ -70,6 +70,22 @@ export async function clearStravaClientCredentials( }); } +/** + * The `User` columns a removed Strava connection leaves behind: no tokens, no + * athlete, and no backfill state. The backfill markers belong to the account + * that was connected. Left standing, a later connection inherited them: a + * completed marker kept its history from ever being read, and a resume point + * made its first run start below the old account's oldest activity, so + * everything newer was never asked for. + */ +export const STRAVA_DISCONNECTED = { + stravaAccessTokenEncrypted: null, + stravaRefreshTokenEncrypted: null, + stravaAthleteId: null, + stravaBackfillCompletedAt: null, + stravaBackfillResumeBefore: null, +} as const; + export interface StravaConnection { accessToken: string; refreshToken: string;