mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Fitbit, WHOOP, and Oura all issue one-time-use refresh tokens. The token persistence did an unguarded read → refresh → write, so two overlapping syncs for one user both read the same stored refresh token and both POST it. The provider honours only the first; the loser's refresh comes back 401, gets classified as reauth, and the connection is parked at `error_reauth` spuriously even though the peer rotated the token successfully. Add a shared `persistRotatedToken` helper that writes the rotated pair with a compare-and-swap on the stored ciphertext (matched verbatim, since AES-GCM re-encryption of the same plaintext differs byte-for-byte). When the conditional update touches zero rows a peer already won the race; re-read the row and reuse the peer's freshly rotated access token instead of erroring. Wire it into all three providers — Fitbit and WHOOP `getValidToken` (proactive expiry refresh, keyed by connection id) and Oura `storeOuraTokens` (reactive 401 refresh, keyed by user id). Both racing callers now end with a usable token and neither drops the connection.
42 lines
1.3 KiB
TypeScript
42 lines
1.3 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { persistRotatedToken } from "../oauth-refresh";
|
|
|
|
describe("persistRotatedToken", () => {
|
|
it("returns the freshly minted access token when the CAS write wins (1 row)", async () => {
|
|
const conditionalUpdate = vi.fn(async () => 1);
|
|
const readPeerAccessToken = vi.fn(async () => "peer");
|
|
|
|
const token = await persistRotatedToken("mine", {
|
|
conditionalUpdate,
|
|
readPeerAccessToken,
|
|
});
|
|
|
|
expect(token).toBe("mine");
|
|
expect(conditionalUpdate).toHaveBeenCalledTimes(1);
|
|
// No re-read on a win.
|
|
expect(readPeerAccessToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reuses the peer's rotated token on a lost race (0 rows)", async () => {
|
|
const conditionalUpdate = vi.fn(async () => 0);
|
|
const readPeerAccessToken = vi.fn(async () => "peer");
|
|
|
|
const token = await persistRotatedToken("mine", {
|
|
conditionalUpdate,
|
|
readPeerAccessToken,
|
|
});
|
|
|
|
expect(token).toBe("peer");
|
|
expect(readPeerAccessToken).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns null when the row vanished after a lost race", async () => {
|
|
const token = await persistRotatedToken("mine", {
|
|
conditionalUpdate: async () => 0,
|
|
readPeerAccessToken: async () => null,
|
|
});
|
|
|
|
expect(token).toBeNull();
|
|
});
|
|
});
|