sync: unlinking a device now revokes its token on the server (issue 2110)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Unlink was local-only. It cleared the server URL, token and cursor from the device, and left the bearer token valid on the server indefinitely — so someone who unlinked because the laptop was being sold or handed on believed they had revoked access when they hadn't. The blocker was identification, not intent: a token pasted from the web app never carried a device id, and /api/auth/me describes the user, not the device row, so DELETE /devices/<id> could only ever have worked for one of the two ways this app can be linked. DELETE /api/auth/devices/self keys off the token in the Authorization header instead, which the caller always holds — one route that works for both paths, owner-scoped like the rest, and no local schema change. Unlinking is never blocked on the network. Wanting to stop syncing is a local decision, so the revoke is attempted first, its outcome carried back, and the link cleared either way. When the token survives — server unreachable, or older than the route — the Sync screen says so in place, with where to revoke it. A toast would have been the wrong shape for that: it disappears, and this is exactly what someone returns to the screen to check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,24 @@ async def test_revoke_device_requires_auth(app):
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_revoke_self_requires_auth(app):
|
||||
client = app.test_client()
|
||||
resp = await client.delete("/api/auth/devices/self")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_revoke_self_without_a_bearer_token_is_a_bad_request(app):
|
||||
# Doubles as the routing check: a session-authenticated caller presents no
|
||||
# device token, so the self-revoke view answers 400 BEFORE any DB access. A 404
|
||||
# here would mean "self" fell through to the id-keyed route as a malformed UUID
|
||||
# — i.e. that the static rule stopped winning.
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
sess["user_id"] = "00000000-0000-0000-0000-000000000001"
|
||||
resp = await client.delete("/api/auth/devices/self")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_device_login_validates_input(app):
|
||||
# Missing credentials → 400 BEFORE any DB access, so it's checkable in the
|
||||
# DB-free unit lane (invalid-cred and success paths are operator-verified).
|
||||
|
||||
Reference in New Issue
Block a user