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>
54 lines
1.8 KiB
Python
54 lines
1.8 KiB
Python
import pytest
|
|
|
|
from thoughtsync.app import create_app
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_app()
|
|
|
|
|
|
async def test_create_device_requires_auth(app):
|
|
# No session cookie and no bearer header → 401 before any DB access.
|
|
client = app.test_client()
|
|
resp = await client.post("/api/auth/devices", json={"name": "phone"})
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_list_devices_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.get("/api/auth/devices")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_revoke_device_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.delete("/api/auth/devices/00000000-0000-0000-0000-000000000000")
|
|
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).
|
|
client = app.test_client()
|
|
resp = await client.post("/api/auth/device-login", json={})
|
|
assert resp.status_code == 400
|