-
Notifications
You must be signed in to change notification settings - Fork 1
fix(coordinator): reset the auth state only on a positive proof #1267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
11c34e4
addb7b6
78a86ce
c72ae45
f4c0129
769c8f9
f5fb8d0
6f8b2cc
a371b64
4143053
a6a3e97
31279d8
063dac8
1256222
e0fae4a
a25112e
fffb685
17a8db3
b3e459d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1266,7 +1266,28 @@ async def _async_update_data(self) -> list[dict[str, Any]]: | |
| self._refresh_canonicless_drop_stats(self._entry_id()) | ||
|
|
||
| # Success path: if we were in an auth error state, clear it now. | ||
| # | ||
| # This is the strongest proof source in the integration, and | ||
| # after the poll loop stopped resetting on an empty result it is | ||
| # also the only everyday one left for the transient counter. | ||
| # `async_get_basic_device_list` has no non-throwing error exit: | ||
| # every except branch ends in `raise ConfigEntryAuthFailed` or | ||
| # `raise UpdateFailed`. A return that got this far therefore | ||
| # means Nova accepted the account token, which is exactly what | ||
| # the counter counts -- and an expired login raises before it can | ||
| # reach this line, so it cannot mask itself here the way it could | ||
| # through an empty location result. | ||
| # | ||
| # Both resets belong in this branch and not above it: the cached | ||
| # branch skips the call entirely and so proves nothing. | ||
| self._set_auth_state(failed=False) | ||
| if self._consecutive_transient_auth_failures > 0: | ||
| _LOGGER.info( | ||
| "Device list refresh succeeded; clearing %d transient auth failure(s).", | ||
| self._consecutive_transient_auth_failures, | ||
| ) | ||
| self._consecutive_transient_auth_failures = 0 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the default configuration, both AGENTS.md reference: custom_components/googlefindmy/AGENTS.md:L117-L119 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @codex review Is this fixed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
At the default 300-second device-list and location cadence, AGENTS.md reference: custom_components/googlefindmy/AGENTS.md:L263-L269 Useful? React with 👍 / 👎. |
||
| self._last_transient_auth_error = None | ||
| self._set_api_status(ApiStatus.OK) | ||
|
|
||
| # Normalize payloads and filter/dedupe devices using pure helpers | ||
|
|
@@ -1788,17 +1809,6 @@ async def _async_start_poll_cycle( | |
| timeout=POLL_DEVICE_OUTER_TIMEOUT_S, | ||
| ) | ||
|
|
||
| # Success path: ensure any previous auth error is cleared | ||
| self._set_auth_state(failed=False) | ||
| # Reset transient auth failure counter on success | ||
| if self._consecutive_transient_auth_failures > 0: | ||
| _LOGGER.info( | ||
| "Location request succeeded; clearing %d transient auth failure(s).", | ||
| self._consecutive_transient_auth_failures, | ||
| ) | ||
| self._consecutive_transient_auth_failures = 0 | ||
| self._last_transient_auth_error = None | ||
|
|
||
| if not location: | ||
| # Expected for BLE tags with no reporter nearby: the | ||
| # inner FCM wait returns an empty result rather than | ||
|
|
@@ -1810,6 +1820,39 @@ async def _async_start_poll_cycle( | |
| ) | ||
| continue | ||
|
|
||
| # A location WITH content, and only that, proves on this | ||
| # path that the credentials worked -- the same rule the | ||
| # sound handlers state at their own sites ("only an | ||
| # ACCEPTED submission proves the credentials worked"). | ||
| # An empty return proves only that nothing raised. | ||
|
Comment on lines
+1923
to
+1927
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After this change, an empty normal return exits before clearing authentication state in both the polling path and AGENTS.md reference: AGENTS.md:L628-L628 Useful? React with 👍 / 👎. |
||
| # | ||
| # The guard above therefore runs FIRST. It used not to, | ||
| # and that was the defect: an empty dict is WEAK evidence | ||
| # that the request was accepted, not proof of it. The 5xx, | ||
| # the 429, the network error and the failed FCM | ||
| # registration raise LocationRequestNotAcceptedError today | ||
| # instead of flattening into {}, but four pre-accept | ||
| # failures still arrive here as an empty dict (an | ||
| # unregistered FCM receiver provider, a provider returning | ||
| # None, a missing token cache, and a failure binding the | ||
| # lazily imported decrypt / eid-info modules), because they | ||
| # are raised before the handler that would convert them. | ||
| # | ||
| # The cost of getting this wrong is not cosmetic. The | ||
| # counter exists to escalate a genuinely expired login | ||
| # after _MAX_TRANSIENT_AUTH_FAILURES cycles; a fleet with | ||
| # one idle BLE tag cleared it on every single pass, so the | ||
| # threshold was never reached and the re-auth prompt this | ||
| # mechanism was built for never appeared. | ||
| self._set_auth_state(failed=False) | ||
| if self._consecutive_transient_auth_failures > 0: | ||
| _LOGGER.info( | ||
| "Location request succeeded; clearing %d transient auth failure(s).", | ||
| self._consecutive_transient_auth_failures, | ||
| ) | ||
| self._consecutive_transient_auth_failures = 0 | ||
| self._last_transient_auth_error = None | ||
|
|
||
| # A device returned an authenticated coordinate report | ||
| # without raising a DecryptionError: positive proof the | ||
| # account-wide shared key still decrypts. Gate the crypto OK | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1002,35 +1002,124 @@ async def test_a_rejected_device_never_clears_the_auth_state() -> None: | |
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_an_empty_return_still_clears_the_counter() -> None: | ||
| """Characterisation of the reset this change deliberately leaves alone. | ||
|
|
||
| An empty result USUALLY MEANS an accepted request that came back without a | ||
| report, and it still counts as success: the counter goes back to zero and | ||
| the auth state is cleared. The precision matters, because the sentence that | ||
| used to stand here -- "that is true today for every 5xx and every 429" -- is | ||
| no longer true. Those raise before they can reach this path, which is | ||
| exactly what the change did; the pair to this test is | ||
| ``test_an_unaccepted_request_no_longer_clears_the_counter``. "Usually" and | ||
| not "always", because four pre-accept faults still arrive as an empty dict; | ||
| they are enumerated at the post-loop guard in ``polling.py``. | ||
|
|
||
| What remains wrong on its own terms is the reset itself: an accepted request | ||
| that returned nothing proves only that nothing raised, not that the | ||
| credentials work. That is tracked as a finding of its own with its own | ||
| approval gate (`PLAN_GFMY_AUTH_RESET_POSITIVE_PROOF`). This test is here so | ||
| the day it changes, it changes on purpose. | ||
| async def test_an_empty_return_proves_nothing_about_the_credentials() -> None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Renaming this test leaves AGENTS.md reference: AGENTS.md:L305-L305 Useful? React with 👍 / 👎. |
||
| """The reset moved behind the empty guard; this is the same test, inverted. | ||
|
|
||
| History, kept deliberately: this function used to be called | ||
| ``test_an_empty_return_still_clears_the_counter`` and asserted the opposite | ||
| of what it asserts now. It was a characterisation of a reset that was known | ||
| to be wrong on its own terms and was left alone at the time, with the | ||
| finding tracked separately. This is the day it changes, and it changes on | ||
| purpose. | ||
|
|
||
| What is wrong with the old behaviour: an empty dict is WEAK evidence that | ||
| the request was accepted, not proof of it. The 5xx, the 429, the network | ||
| error and the failed FCM registration now raise | ||
| ``LocationRequestNotAcceptedError`` instead of flattening into ``{}``, but | ||
| four pre-accept failures still arrive here as an empty dict (enumerated in | ||
| ``test_a_cycle_of_only_empty_results_still_reports_success``). Clearing the | ||
| auth state and the transient counter on that evidence is the false-success | ||
| reasoning this change removes: the counter exists to escalate a genuinely | ||
| expired login after three cycles, and a fleet with one idle BLE tag reset | ||
| it in every single cycle, so the threshold was never reached. | ||
|
|
||
| The positive half is not given up, it moves: a location WITH content still | ||
| clears both, which is ``test_a_real_location_still_clears_the_auth_state`` | ||
| and ``test_a_real_location_still_resets_the_transient_counter``. | ||
| """ | ||
| coordinator = _polling_coordinator({}, _TrackingFilter(), {}) | ||
| coordinator.api = _PerDeviceAPI({"dev-1": {}}) | ||
| auth_calls: list[dict[str, Any]] = [] | ||
| coordinator._set_auth_state = lambda **kwargs: auth_calls.append(kwargs) | ||
| coordinator._consecutive_transient_auth_failures = 2 | ||
| coordinator._last_transient_auth_error = "expired" | ||
|
|
||
| await coordinator._async_start_poll_cycle([{"id": "dev-1", "name": "Hub"}]) | ||
|
|
||
| assert not [kw for kw in auth_calls if kw.get("failed") is False] | ||
| assert coordinator._consecutive_transient_auth_failures == 2 | ||
| assert coordinator._last_transient_auth_error == "expired" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_a_real_location_still_clears_the_auth_state() -> None: | ||
| """The positive half of the rule must survive the fix. | ||
|
|
||
| Moving the reset behind the empty guard is only correct if a location WITH | ||
| content still counts as proof. Without this test the fix could be "improved" | ||
| into removing the reset altogether, which would strand a pending auth error | ||
| until the next device-list refresh. | ||
| """ | ||
| coordinator = _polling_coordinator({}, _TrackingFilter(), {}) | ||
| coordinator.api = _PerDeviceAPI( | ||
| { | ||
| "dev-1": { | ||
| "latitude": 50.0, | ||
| "longitude": 10.0, | ||
| "accuracy": 5.0, | ||
| "last_seen": 100.0, | ||
| } | ||
| } | ||
| ) | ||
| auth_calls: list[dict[str, Any]] = [] | ||
| coordinator._set_auth_state = lambda **kwargs: auth_calls.append(kwargs) | ||
|
|
||
| await coordinator._async_start_poll_cycle([{"id": "dev-1", "name": "Hub"}]) | ||
|
|
||
| assert [kw for kw in auth_calls if kw.get("failed") is False] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_a_real_location_still_resets_the_transient_counter() -> None: | ||
| """Same as above for the counter and the stored cause. | ||
|
|
||
| Separate from the auth-state test on purpose: the two live in one block | ||
| today, and a fix that moves only one of them would otherwise pass. | ||
| """ | ||
| coordinator = _polling_coordinator({}, _TrackingFilter(), {}) | ||
| coordinator.api = _PerDeviceAPI( | ||
| { | ||
| "dev-1": { | ||
| "latitude": 50.0, | ||
| "longitude": 10.0, | ||
| "accuracy": 5.0, | ||
| "last_seen": 100.0, | ||
| } | ||
| } | ||
| ) | ||
| coordinator._set_auth_state = lambda **kwargs: None | ||
| coordinator._consecutive_transient_auth_failures = 2 | ||
| coordinator._last_transient_auth_error = "expired" | ||
|
|
||
| await coordinator._async_start_poll_cycle([{"id": "dev-1", "name": "Hub"}]) | ||
|
|
||
| assert coordinator._consecutive_transient_auth_failures == 0 | ||
| assert coordinator._last_transient_auth_error is None | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_an_empty_return_does_not_clear_a_pending_auth_error() -> None: | ||
| """The cross-device case, which is why the order of the guard matters. | ||
|
|
||
| One tracker raises a credential failure, a second one comes back empty in | ||
| the same cycle. With the reset in front of the empty guard, the second | ||
| device wiped the first device's finding on every pass -- and in a fleet with | ||
| one idle BLE tag that is every pass, forever. The devices are ordered so the | ||
| empty one is polled last, which is the order that used to lose the finding. | ||
| """ | ||
| coordinator = _polling_coordinator({}, _TrackingFilter(), {}) | ||
| coordinator.api = _PerDeviceAPI( | ||
| {"dev-1": NovaAuthError(401, "expired"), "dev-2": {}} | ||
| ) | ||
| auth_calls: list[dict[str, Any]] = [] | ||
| coordinator._set_auth_state = lambda **kwargs: auth_calls.append(kwargs) | ||
| coordinator.config_entry.async_start_reauth = MagicMock() | ||
|
|
||
| await coordinator._async_start_poll_cycle( | ||
| [{"id": "dev-1", "name": "Hub"}, {"id": "dev-2", "name": "Tag"}] | ||
| ) | ||
|
|
||
| assert not [kw for kw in auth_calls if kw.get("failed") is False] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
|
|
@@ -1741,15 +1830,17 @@ async def test_an_unaccepted_device_does_not_touch_the_transient_auth_counter() | |
|
|
||
| @pytest.mark.asyncio | ||
| async def test_an_unaccepted_request_no_longer_clears_the_counter() -> None: | ||
| """The contract pair to ``test_an_empty_return_still_clears_the_counter``. | ||
| """The contract pair to | ||
| ``test_an_empty_return_proves_nothing_about_the_credentials``. | ||
|
|
||
| The two are deliberately adjacent claims about the same success path, read | ||
| from opposite sides. An ACCEPTED request that came back empty still clears | ||
| the counter -- that reset is wrong on its own terms and is tracked | ||
| separately (`PLAN_GFMY_AUTH_RESET_POSITIVE_PROOF`), so it is characterised, | ||
| not changed here. A request that was never accepted no longer reaches that | ||
| path at all. Splitting them is what makes the difference between the two | ||
| outcomes checkable instead of a matter of reading the branch. | ||
| from opposite sides. A request that was never accepted does not reach that | ||
| path at all; a request that WAS accepted but came back empty reaches it and | ||
| no longer clears anything either, because an empty dict is weak evidence of | ||
| acceptance rather than proof of working credentials. The two mechanisms are | ||
| different and stay separate: one is a raise before the success path, the | ||
| other is a guard inside it. Splitting them is what makes the difference | ||
| between the outcomes checkable instead of a matter of reading the branch. | ||
| """ | ||
| coordinator = _polling_coordinator({}, _TrackingFilter(), {}) | ||
| coordinator.api = _PerDeviceAPI( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This newly added comment still calls the device-list success path the transient counter's “only everyday” source, although this branch explicitly does not reset that counter at lines 1290-1293; the everyday reset now occurs after a clean poll cycle at lines 2594-2655. Fresh evidence since the earlier review is that the component contract was corrected to describe the cycle-level reset, while this contradictory in-code explanation remains and could encourage reintroducing the cadence bug.
AGENTS.md reference: custom_components/googlefindmy/AGENTS.md:L286-L296
Useful? React with 👍 / 👎.