fix(oura): keep the scope note when a run never proved the token

The refresh-abort paths (no client credentials, refresh token lost to a
peer) and a run where no collection succeeded wrote an empty scope list,
clearing the reconnect hint until the next good run. Such a run learned
nothing about the grant, so the stored note now stands.
This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:47:37 +02:00
1 parent 55ddad3d6a
commit 530d1a6be2
2 files changed
+52 -14

No files matched your search

+32 -1
View File
@@ -1017,9 +1017,40 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () =
const result = await syncUserOura("u1");
expect(result.failed).toBe(true);
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []);
// Nothing proved the token, so nothing was learned about the grant.
expect(scopeDeniedMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({ integration: "oura", kind: "reauth_required" }),
);
});
it("keeps the scope note when the refresh is abandoned for want of credentials", async () => {
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
// A new 401 the note does not explain sends the run to the refresh.
fetchSleepMock.mockRejectedValue(err401());
denyScopedCollections();
getCredsMock.mockResolvedValue(null);
await syncUserOura("u1");
expect(refreshMock).not.toHaveBeenCalled();
expect(scopeDeniedMock).not.toHaveBeenCalled();
});
it("keeps the scope note when the refresh loses its token to a peer", async () => {
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
fetchSleepMock.mockRejectedValue(err401());
denyScopedCollections();
refreshMock.mockResolvedValue({
access_token: "newAcc",
refresh_token: "newRef",
expires_in: 86400,
});
storeTokensMock.mockResolvedValue(null);
await syncUserOura("u1");
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(scopeDeniedMock).not.toHaveBeenCalled();
});
});
+20 -13
View File
@@ -246,11 +246,13 @@ export interface OuraFetchResult {
*/
sleepSweeps: SleepSegmentSweep[];
/**
* Collections refused with 401/403 while a sibling succeeded with the same
* Collections refused with 401 while a sibling succeeded with the same
* token: the token is fine, the grant does not cover them. Not failures; the
* card asks for one reconnect instead.
* card asks for one reconnect instead. `null` when the run never proved the
* token (no collection succeeded, or the refresh was abandoned): it learned
* nothing about the grant, so the note from the last run that did stands.
*/
scopeDenied: string[];
scopeDenied: string[] | null;
}
/**
@@ -286,8 +288,8 @@ function isScopeRefusal(f: OuraCollectionFailure): boolean {
function splitScopeDenied(
failures: OuraCollectionFailure[],
tokenProven: boolean,
): { failures: OuraCollectionFailure[]; scopeDenied: string[] } {
if (!tokenProven) return { failures, scopeDenied: [] };
): { failures: OuraCollectionFailure[]; scopeDenied: string[] | null } {
if (!tokenProven) return { failures, scopeDenied: null };
return {
failures: failures.filter((f) => !isScopeRefusal(f)),
scopeDenied: failures.filter(isScopeRefusal).map((f) => f.name),
@@ -490,7 +492,7 @@ async function fetchAll(
readings: first.readings,
failures: first.failures,
sleepSweeps,
scopeDenied: [],
scopeDenied: null,
};
const rotated = await refreshAccessToken(refreshToken, creds);
@@ -508,7 +510,7 @@ async function fetchAll(
readings: first.readings,
failures: first.failures,
sleepSweeps,
scopeDenied: [],
scopeDenied: null,
};
const retry = await attempt(usableToken, first.failed);
@@ -565,15 +567,20 @@ export async function syncUserOura(
// Remember which collections the grant does not cover, so the settings card
// can ask for one reconnect. Best-effort: the note is a convenience, the
// sync outcome does not depend on it.
if (result.scopeDenied.length > 0) {
// A run that never proved the token says nothing about the grant and leaves
// the note as it was.
const scopeDenied = result.scopeDenied;
if (scopeDenied && scopeDenied.length > 0) {
annotate({
meta: { oura_scope_denied: result.scopeDenied },
meta: { oura_scope_denied: scopeDenied },
});
}
try {
await syncOuraScopeDenied(userId, result.scopeDenied);
} catch (err) {
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
if (scopeDenied) {
try {
await syncOuraScopeDenied(userId, scopeDenied);
} catch (err) {
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
}
}
// Clear whatever an earlier scoring left under the re-fetched sleep records