mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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.
115 lines
3.4 KiB
TypeScript
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();
|
|
});
|
|
});
|