diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py new file mode 100644 index 00000000..57a80fc0 --- /dev/null +++ b/backend/tests/test_auth_session.py @@ -0,0 +1,98 @@ +"""Auth / session security core (TEST-1), DB-lane. + +The wallet impersonation gate, epoch-based session revocation, single-use tokens and the +enumeration-safe uniform 401 are the security mag, and had zero tests (only `_oauth_redirect_uri` was +covered). Starlette's SessionMiddleware round-trips the signed cookie under TestClient, so the whole +browser-less flow is exercisable. Uses the real auth stack via the `client` fixture (NO current_user +override) so login, the wallet gate and epoch revoke are the code under test. + +Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False}` when logged out, not a +401. A revoked session is therefore checked on a PROTECTED endpoint (`/api/feed`), which does 401. +""" +from datetime import timedelta + +import pytest +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import ACTIVE_ACCOUNT_HEADER +from app.main import app + +_PW = "correct horse battery" + + +@pytest.fixture(autouse=True) +def _configured(monkeypatch): + # The setup gate 503s /auth and /api until the wizard has run; every test here is post-setup. + monkeypatch.setattr(state, "_configured_cache", True) + + +def _login(client, email, password): + return client.post("/auth/password-login", json={"email": email, "password": password}) + + +def test_password_login_then_me_is_authenticated(client, make_user): + make_user(email="alice@example.com", password=_PW) + assert _login(client, "alice@example.com", _PW).status_code == 200 + me = client.get("/api/me") + assert me.status_code == 200 + assert me.json()["authenticated"] is True + assert me.json()["email"] == "alice@example.com" + + +def test_wrong_password_and_unknown_email_give_a_byte_identical_401(client, make_user): + make_user(email="bob@example.com", password=_PW) + wrong_pw = _login(client, "bob@example.com", "not the password") + unknown = _login(client, "nobody@example.com", _PW) + assert wrong_pw.status_code == 401 and unknown.status_code == 401 + # Identical body: a wrong password and a non-existent account must be indistinguishable (no + # account-enumeration oracle). + assert wrong_pw.json() == unknown.json() + + +def test_a_non_wallet_account_header_falls_back_to_the_default(client, make_user): + # The X-Siftlode-Account header may only re-target an account already signed into THIS browser + # (in the session wallet). A header naming someone else must be ignored, not honored — otherwise + # it's an impersonation gate hole. + make_user(email="alice2@example.com", password=_PW) + mallory = make_user(email="mallory@example.com", password=_PW) + assert _login(client, "alice2@example.com", _PW).status_code == 200 + me = client.get("/api/me", headers={ACTIVE_ACCOUNT_HEADER: str(mallory.id)}) + assert me.status_code == 200 + assert me.json()["email"] == "alice2@example.com" # still Alice, not Mallory + + +def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user): + # Two independent cookie jars (browsers) signed into the same account. `logout-others` from one + # bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller + # is re-stamped and stays in. Checked on /api/feed (a protected endpoint that 401s on revocation). + make_user(email="carol@example.com", password=_PW) + # A second, independent cookie jar; the `client` fixture already routed get_db to the test DB on + # the shared app, so this instance uses it too — it just has its own session cookie. + other = TestClient(app) + assert _login(client, "carol@example.com", _PW).status_code == 200 + assert _login(other, "carol@example.com", _PW).status_code == 200 + assert other.get("/api/feed").status_code == 200 # both valid to start + + assert client.post("/auth/logout-others").status_code == 200 + assert other.get("/api/feed").status_code == 401 # its epoch is now stale + assert client.get("/api/feed").status_code == 200 # the caller stays signed in + + +def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_user, db): + from app.routes.auth import _issue_token + + user = make_user(email="dave@example.com", password=_PW) + assert _login(client, "dave@example.com", _PW).status_code == 200 + assert client.get("/api/feed").status_code == 200 # session valid at the pre-reset epoch + + raw = _issue_token(db, user, "reset", timedelta(hours=1)) + db.commit() + ok = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "brand new pw!"}) + assert ok.status_code == 200 + + # A reset bumps the epoch (compromise response) -> the pre-reset cookie is now rejected. + assert client.get("/api/feed").status_code == 401 + # The token is single-use: a replay is a clean 400, not a second reset. + replay = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "another pw!!"}) + assert replay.status_code == 400