mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(oura): read a 401 as a scope gap only on the scope-gated collections
A 401 on any collection next to a success was recorded as a scope gap, so a real auth failure on sleep, readiness or activity was hidden from the failure ledger and shown as a reconnect hint. Only spo2, vo2max, cardio_age and resilience sit behind a scope beyond daily; a 401 anywhere else keeps its normal failure handling, and a stored note naming another collection no longer skips the token refresh.
This commit is contained in:
1 parent
c7164be0d3
commit
55ddad3d6a
2 files changed
+69
-11
No files matched your search
@@ -950,6 +950,50 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () =
|
||||
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
|
||||
});
|
||||
|
||||
it("keeps a 401 on a core collection a failure even when siblings succeed", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
fetchReadinessMock.mockResolvedValue([
|
||||
{ id: "x", day: "2026-06-10", score: 70 },
|
||||
]);
|
||||
fetchSleepMock.mockRejectedValue(err401());
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.failed).toBe(true);
|
||||
expect(scopeDeniedMock).not.toHaveBeenCalledWith(
|
||||
"u1",
|
||||
expect.arrayContaining(["sleep"]),
|
||||
);
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ integration: "oura" }),
|
||||
);
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let a stored core name skip the refresh", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: ["sleep"] });
|
||||
fetchReadinessMock.mockResolvedValue([
|
||||
{ id: "x", day: "2026-06-10", score: 70 },
|
||||
]);
|
||||
fetchSleepMock.mockRejectedValueOnce(err401()).mockResolvedValue([]);
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.failed).toBe(false);
|
||||
});
|
||||
|
||||
it("still treats a 401 on every collection as a dead token", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
for (const m of [
|
||||
|
||||
+25
-11
@@ -91,6 +91,7 @@ import {
|
||||
} from "./credentials";
|
||||
import { OuraApiError, classifyOuraError } from "./response-classifier";
|
||||
import { syncUserOuraCyclePhases } from "./cycle-sync";
|
||||
import { OURA_SCOPE_GATED_COLLECTIONS } from "./scope";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
@@ -261,13 +262,26 @@ function isAuthRefusal(err: unknown): boolean {
|
||||
return err instanceof OuraApiError && err.httpStatus === 401;
|
||||
}
|
||||
|
||||
const SCOPE_GATED: ReadonlySet<string> = new Set(OURA_SCOPE_GATED_COLLECTIONS);
|
||||
|
||||
/**
|
||||
* Split the auth refusals out of a run's failures once a sibling collection has
|
||||
* proven the token. Oura's authorization server enforces scopes per collection,
|
||||
* so a grant issued without `spo2` / `heart_health` answers 401 on exactly the
|
||||
* collections behind them and 200 everywhere else. Counting those toward the
|
||||
* reconnect ladder painted a working connection as broken on every sync, and
|
||||
* refreshing the token over them changed nothing.
|
||||
* A 401 that can be the grant's scope rather than the token: only the
|
||||
* collections behind `spo2` / `heart_health` sit outside the `daily` scope
|
||||
* every grant carries. A 401 on sleep, readiness or activity is a real auth
|
||||
* failure whatever its siblings did.
|
||||
*/
|
||||
function isScopeRefusal(f: OuraCollectionFailure): boolean {
|
||||
return SCOPE_GATED.has(f.name) && isAuthRefusal(f.err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the scope refusals out of a run's failures once a sibling collection
|
||||
* has proven the token. Oura's authorization server enforces scopes per
|
||||
* collection, so a grant issued without `spo2` / `heart_health` answers 401 on
|
||||
* exactly the collections behind them and 200 everywhere else. Counting those
|
||||
* toward the reconnect ladder painted a working connection as broken on every
|
||||
* sync, and refreshing the token over them changed nothing. Any other failure,
|
||||
* a 401 on a core collection included, stays a failure.
|
||||
*/
|
||||
function splitScopeDenied(
|
||||
failures: OuraCollectionFailure[],
|
||||
@@ -275,10 +289,8 @@ function splitScopeDenied(
|
||||
): { failures: OuraCollectionFailure[]; scopeDenied: string[] } {
|
||||
if (!tokenProven) return { failures, scopeDenied: [] };
|
||||
return {
|
||||
failures: failures.filter((f) => !isAuthRefusal(f.err)),
|
||||
scopeDenied: failures
|
||||
.filter((f) => isAuthRefusal(f.err))
|
||||
.map((f) => f.name),
|
||||
failures: failures.filter((f) => !isScopeRefusal(f)),
|
||||
scopeDenied: failures.filter(isScopeRefusal).map((f) => f.name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -455,7 +467,9 @@ async function fetchAll(
|
||||
// to collections that succeeded, is the grant's scope again: a refresh cannot
|
||||
// widen it, so none is spent. Any other 401 still takes the one refresh, and
|
||||
// whatever 401s after it beside a success is recorded as scope.
|
||||
const knownDenied = new Set(knownScopeDenied);
|
||||
const knownDenied = new Set(
|
||||
knownScopeDenied.filter((name) => SCOPE_GATED.has(name)),
|
||||
);
|
||||
const onlyKnownScope401 = first.failures
|
||||
.filter((f) => isAuthRefusal(f.err))
|
||||
.every((f) => knownDenied.has(f.name));
|
||||
|
||||
Reference in new issue
Block a user