diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index 9c6c687..0b183aa 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -310,6 +310,23 @@ def _render_claim(request: Request, db: Session, device_id: str, error: str | No frame.owner_user_id == user.id or db.get(UserFrame, (user.id, frame.id)) is not None ): status, pending_yours = "claimed_yours", False + if frame.device_token_ack: + # The device's captive portal redirects here on EVERY + # (re)provisioning cycle (see wifi_provisioning.c) -- if the + # physical frame was reset/reprovisioned, it no longer has + # the access token this frame row already acknowledged, and + # auth.require_device permanently locks out an id-only + # request once device_token_ack is set (device_id alone, + # unlike the token, isn't secret -- it's shown on the + # frame's own screen/QR). Reopening that handshake window + # here is what "give it a minute to connect" below actually + # depends on: it's safe because landing on this branch + # already requires knowing the device_id (physical/local + # access to the frame) AND being logged in as an owner/ + # linked user of it. + frame.device_token_ack = False + db.commit() + logger.info("Frame #%d's device token handshake reopened (re-provisioned)", frame.id) else: status, pending_yours = "claimed", False return templates.TemplateResponse( diff --git a/server/tests/test_claim_reprovision.py b/server/tests/test_claim_reprovision.py new file mode 100644 index 0000000..0e740bc --- /dev/null +++ b/server/tests/test_claim_reprovision.py @@ -0,0 +1,82 @@ +"""A physical frame's device_token_ack becomes permanently sticky once +set (see app/auth.py's require_device) -- if the device is reset/ +reprovisioned, it loses its access token and gets locked out with a 401 +forever unless something reopens that handshake window. The device's +own captive portal redirects the phone's browser to /claim?device_id= +on every (re)provisioning cycle (see firmware/main/wifi_provisioning.c), +so pages.py's _render_claim uses that as the hook to auto-reset the ack +flag for a logged-in owner/linked user -- see routers/pages.py.""" + +from __future__ import annotations + +from app.models import Frame + +from .conftest import link_user, login, make_user + + +def test_claim_page_resets_device_token_ack_for_owner(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + frame.device_id = "a0f262865898" + frame.device_token_ack = True + db_session.commit() + + resp = client.get(f"/claim?device_id={frame.device_id}") + assert resp.status_code == 200 + + db_session.refresh(frame) + assert frame.device_token_ack is False + + +def test_claim_page_resets_device_token_ack_for_linked_user(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + bob = make_user(db_session, "bob") + frame = db_session.get(Frame, 1) + link_user(db_session, bob, frame) + frame.device_id = "a0f262865898" + frame.device_token_ack = True + db_session.commit() + + client.cookies.clear() + login(client, "bob") + + resp = client.get(f"/claim?device_id={frame.device_id}") + assert resp.status_code == 200 + + db_session.refresh(frame) + assert frame.device_token_ack is False + + +def test_claim_page_leaves_ack_alone_when_already_false(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + frame.device_id = "a0f262865898" + db_session.commit() + assert frame.device_token_ack is False + + resp = client.get(f"/claim?device_id={frame.device_id}") + assert resp.status_code == 200 + + db_session.refresh(frame) + assert frame.device_token_ack is False + + +def test_claim_page_does_not_reset_ack_for_unrelated_user(client, db_session): + """A user with no relationship to the frame lands on the "claimed" + (not "claimed_yours") branch -- must not be able to reopen another + household's device handshake just by knowing its device_id.""" + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + frame.device_id = "a0f262865898" + frame.device_token_ack = True + db_session.commit() + + make_user(db_session, "mallory") + client.cookies.clear() + login(client, "mallory") + + resp = client.get(f"/claim?device_id={frame.device_id}") + assert resp.status_code == 200 + + db_session.refresh(frame) + assert frame.device_token_ack is True