Files
HealthLog/src/lib/strava/__tests__/credentials.test.ts
T
Marc-André Bombeck 75eb0637e6 feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped
WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so
each self-hoster registers their own app and pastes the client id/secret into
Settings (falling back to a shared env app). Strava is a workout source only —
it exposes no daily metrics and no training-depth analytics are derived.

- Schema (migration 0233): per-user encrypted client id/secret + access/refresh
  tokens, plaintext athlete id, incremental cursor, and backfill marker on
  `User`; `STRAVA` added to `MeasurementSource`.
- Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on
  the stored refresh ciphertext, because Strava rotates its refresh token on
  every refresh.
- Client over safeFetch: authorization-code + refresh, activity list + budget-
  aware per-activity detail (for calories/HR), honouring the 200/15min cap; each
  activity maps to one `Workout` row. Activity name/description stay in metadata
  as data, never surfaced or interpolated.
- Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots
  into the workout ladder below the device-native wearables and above
  MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row.
- Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the
  integration-sync registrar. Webhook deferred.
- OAuth routes (connect/callback/credentials/disconnect/status/test) with signed
  state, cross-account re-auth rejection, and a surgical `www.strava.com`
  connect-src gated to the Strava surfaces.
- Settings card + consolidated status envelope entry; env + compose whitelist;
  i18n across all six locales.
- Garmin: no direct connector is possible (business-partner-only). Add
  docs/integrations/garmin.md documenting the Apple Health / Google Health
  Connect path and Garmin's withheld proprietary scores, cross-linked from both
  platform docs, plus a non-connector info note in the Connections panel.

Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
2026-07-09 21:25:25 +02:00

115 lines
3.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const findUnique = vi.fn();
const updateMany = vi.fn();
const getStravaCredentials = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: (...args: unknown[]) => findUnique(...args),
updateMany: (...args: unknown[]) => updateMany(...args),
},
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (cipher: string) => `dec(${cipher})`,
encrypt: (plain: string) => `enc(${plain})`,
}));
vi.mock("../client", () => ({
getStravaCredentials: () => getStravaCredentials(),
}));
import { getStravaClientCredentials, storeStravaTokens } from "../credentials";
describe("getStravaClientCredentials — DB-first then env", () => {
beforeEach(() => {
findUnique.mockReset();
getStravaCredentials.mockReset();
});
it("returns the per-user BYO pair when both columns are set", async () => {
findUnique.mockResolvedValue({
stravaClientIdEncrypted: "enc-id",
stravaClientSecretEncrypted: "enc-secret",
});
const creds = await getStravaClientCredentials("user-1");
expect(creds).toEqual({
clientId: "dec(enc-id)",
clientSecret: "dec(enc-secret)",
});
expect(getStravaCredentials).not.toHaveBeenCalled();
});
it("falls back to the shared env app when the user has no BYO pair", async () => {
findUnique.mockResolvedValue({
stravaClientIdEncrypted: null,
stravaClientSecretEncrypted: null,
});
getStravaCredentials.mockReturnValue({
clientId: "env-id",
clientSecret: "env-secret",
});
const creds = await getStravaClientCredentials("user-1");
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
expect(getStravaCredentials).toHaveBeenCalledOnce();
});
});
describe("storeStravaTokens — rotating-refresh compare-and-swap", () => {
beforeEach(() => {
findUnique.mockReset();
updateMany.mockReset();
});
it("persists and returns the fresh access token when the CAS wins", async () => {
updateMany.mockResolvedValue({ count: 1 });
const token = await storeStravaTokens(
"user-1",
"fresh-access",
"fresh-refresh",
"stored-refresh-ciphertext",
);
expect(token).toBe("fresh-access");
// The guard keys on the exact stored ciphertext, not a re-encryption.
expect(updateMany).toHaveBeenCalledWith({
where: {
id: "user-1",
stravaRefreshTokenEncrypted: "stored-refresh-ciphertext",
},
data: {
stravaAccessTokenEncrypted: "enc(fresh-access)",
stravaRefreshTokenEncrypted: "enc(fresh-refresh)",
},
});
});
it("returns the peer's rotated access token when the CAS loses the race", async () => {
updateMany.mockResolvedValue({ count: 0 });
findUnique.mockResolvedValue({
stravaAccessTokenEncrypted: "peer-access-cipher",
});
const token = await storeStravaTokens(
"user-1",
"fresh-access",
"fresh-refresh",
"stale-ciphertext",
);
expect(token).toBe("dec(peer-access-cipher)");
});
it("returns null when the connection vanished mid-race", async () => {
updateMany.mockResolvedValue({ count: 0 });
findUnique.mockResolvedValue({ stravaAccessTokenEncrypted: null });
const token = await storeStravaTokens(
"user-1",
"fresh-access",
"fresh-refresh",
"stale-ciphertext",
);
expect(token).toBeNull();
});
});