fix(strava): clear the backfill markers when the connection is removed

Disconnecting Strava, or deleting its credentials, cleared the tokens
and the athlete but kept stravaBackfillCompletedAt and
stravaBackfillResumeBefore. A later connection inherited them: a
completed marker kept its history from being read, and a resume point
started its first walk below the previous account's oldest activity.
Both paths now clear one shared set of columns.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 11:02:50 +02:00
1 parent 742705eadd
commit bd00845be3
5 files changed
+95 -13

No files matched your search

@@ -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<typeof import("@/lib/strava/credentials")>()
).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,
},
}),
);
+2 -5
View File
@@ -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);
@@ -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: <T extends (...args: unknown[]) => 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<typeof import("@/lib/strava/credentials")>()
).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<typeof vi.fn>;
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,
},
});
});
});
+5 -6
View File
@@ -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 });
+16
View File
@@ -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;