mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user