* feat(auth): password reset & invite flows for web + admin (#34) Wires end-to-end the previously orphaned password_reset.html and password_setup.html templates, adds the missing POST /auth/password/reset handler (closes #34), and restores the Reset action in the admin user UI (which origin/main had removed precisely because the flow was broken). Web flow - GET /auth/password/reset — renders the set-new-password form - POST /auth/password/reset — 'Forgot Password?' request; emails link, anti-enumeration (same response for unknown email) - POST /auth/password/reset/confirm — validates token + 24h TTL, sets new password, clears token, logs user in - GET /auth/password/setup — renders the setup form (invite link landing) - POST /auth/password/setup/request — signup-tab 'Request Access' (email-only) - POST /auth/password/setup/confirm — 7-day TTL, sets password + name, logs in - Reuses LOCAL_DEV_MODE pattern from email.py: logs the link loudly so developers can use the flow without an SMTP/SendGrid transport Admin flow - POST /api/users accepts send_invite → returns invite_url + invite_email_sent - POST /api/users/{id}/reset-password now returns a full reset_url pointing at the dedicated password-reset endpoint (NOT the magic-link verifier, which would log the user in without prompting for a new password) - admin_users.html: restored Reset row action, copyable reset/invite link modals, invite checkbox on create, reworded 'magic-link not wired' notes Backward compat - JSON POST /auth/password/setup kept unchanged (existing tests pass) - Active-account gate applied to reset/setup flows (matches password_login) Tests: 21 new cases (tests/test_password_flows.py) covering GET renders, request/confirm happy + error paths, TTLs, anti-enumeration, and admin invite/reset URL responses. Full suite: 1309 passed. Closes #34 * fix(admin-users): allow horizontal scroll when actions overflow Four action buttons (Tokens, Reset, Set pwd, Delete) can exceed the viewport on narrow screens. Switch .users-table-wrap from overflow: hidden to overflow-x: auto so the table scrolls instead of clipping, and lock row-actions buttons to a single nowrap line. * fix(admin-users): override base 800px container so table can use full width The base layout caps .container at 800px, so the table was always being clipped regardless of viewport. Unclamp the container on this page and widen the inner page cap to 1400px. * fix(auth): address Devin review — harden JSON setup, anti-enumeration, preserve email case Addresses findings from Devin review on PR #37: 1. JSON POST /auth/password/setup now enforces the same SETUP_TOKEN_TTL (7 days) and active-account check as the web flow. An expired token or a deactivated user can no longer bypass the gate by posting JSON. Existing test fixture seeds setup_token_created=now so backward-compat tests continue to pass. 2. GET /auth/password/setup no longer looks up the user to pre-fill name. The form renders identically regardless of whether the email exists, consistent with anti-enumeration in POST /setup/request. 3. reset_request / setup_request no longer lowercase the submitted email. The rest of the codebase (password_login, magic-link, admin create) uses case-sensitive lookups, so normalizing only here would silently fail for mixed-case accounts. Tests: 6 new cases covering expired-JSON-setup, missing-created-timestamp, deactivated-user-rejection, mixed-case email preservation, and the anti-enumeration property of GET /setup.
165 lines
6.5 KiB
Python
165 lines
6.5 KiB
Python
"""Tests for auth providers — password, email magic link, google OAuth."""
|
|
|
|
import os
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-32chars-minimum!!!!!")
|
|
|
|
from app.main import create_app
|
|
from src.db import get_system_db
|
|
from src.repositories.users import UserRepository
|
|
|
|
conn = get_system_db()
|
|
ur = UserRepository(conn)
|
|
# User with password
|
|
try:
|
|
from argon2 import PasswordHasher
|
|
ph = PasswordHasher()
|
|
pw_hash = ph.hash("testpass123")
|
|
except ImportError:
|
|
import hashlib
|
|
pw_hash = hashlib.sha256(b"testpass123").hexdigest()
|
|
|
|
ur.create(id="pw1", email="pw@test.com", name="PW User", role="analyst", password_hash=pw_hash)
|
|
# User with setup token (and fresh created timestamp so the JSON /setup
|
|
# endpoint's TTL check accepts it)
|
|
from datetime import datetime, timezone
|
|
ur.create(id="setup1", email="setup@test.com", name="Setup User", role="analyst")
|
|
ur.update(id="setup1", setup_token="setup-token-123",
|
|
setup_token_created=datetime.now(timezone.utc))
|
|
# User for magic link
|
|
ur.create(id="ml1", email="ml@test.com", name="ML User", role="analyst")
|
|
conn.close()
|
|
|
|
app = create_app()
|
|
return TestClient(app)
|
|
|
|
|
|
class TestTokenEndpoint:
|
|
"""Tests for /auth/token — password bypass fix."""
|
|
|
|
def test_token_empty_password_rejected_when_user_has_hash(self, client):
|
|
"""Empty password must be rejected when user has password_hash."""
|
|
resp = client.post("/auth/token", json={"email": "pw@test.com", "password": ""})
|
|
assert resp.status_code == 401
|
|
|
|
def test_token_missing_password_rejected_when_user_has_hash(self, client):
|
|
"""Omitting password field (defaults to '') must be rejected when user has password_hash."""
|
|
resp = client.post("/auth/token", json={"email": "pw@test.com"})
|
|
assert resp.status_code == 401
|
|
|
|
def test_token_wrong_password_rejected(self, client):
|
|
"""Wrong password must be rejected with 401."""
|
|
resp = client.post("/auth/token", json={"email": "pw@test.com", "password": "wrongpass"})
|
|
assert resp.status_code == 401
|
|
|
|
def test_token_correct_password_succeeds(self, client):
|
|
"""Correct password must issue a token."""
|
|
resp = client.post("/auth/token", json={"email": "pw@test.com", "password": "testpass123"})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "access_token" in data
|
|
assert data["email"] == "pw@test.com"
|
|
|
|
def test_token_no_password_hash_user_gets_token(self, client):
|
|
"""User without password_hash (OAuth-only) must be rejected at /auth/token."""
|
|
resp = client.post("/auth/token", json={"email": "ml@test.com"})
|
|
assert resp.status_code == 401
|
|
|
|
def test_token_rejected_for_oauth_only_user(self, client):
|
|
"""OAuth-only user (no password_hash) must not receive a token via /auth/token."""
|
|
resp = client.post("/auth/token", json={"email": "ml@test.com"})
|
|
assert resp.status_code == 401
|
|
assert "external authentication" in resp.json()["detail"]
|
|
|
|
def test_token_unknown_user_rejected(self, client):
|
|
"""Unknown email must return 401."""
|
|
resp = client.post("/auth/token", json={"email": "nobody@test.com", "password": "anything"})
|
|
assert resp.status_code == 401
|
|
|
|
|
|
class TestPasswordAuth:
|
|
def test_login_success(self, client):
|
|
resp = client.post("/auth/password/login", json={
|
|
"email": "pw@test.com", "password": "testpass123",
|
|
})
|
|
assert resp.status_code == 200
|
|
assert "access_token" in resp.json()
|
|
|
|
def test_login_wrong_password(self, client):
|
|
resp = client.post("/auth/password/login", json={
|
|
"email": "pw@test.com", "password": "wrongpass",
|
|
})
|
|
assert resp.status_code == 401
|
|
|
|
def test_login_unknown_user(self, client):
|
|
resp = client.post("/auth/password/login", json={
|
|
"email": "unknown@test.com", "password": "test",
|
|
})
|
|
assert resp.status_code == 401
|
|
|
|
def test_setup_password(self, client):
|
|
resp = client.post("/auth/password/setup", json={
|
|
"email": "setup@test.com", "token": "setup-token-123", "password": "newpass456",
|
|
})
|
|
assert resp.status_code == 200
|
|
assert "access_token" in resp.json()
|
|
|
|
def test_setup_wrong_token(self, client):
|
|
resp = client.post("/auth/password/setup", json={
|
|
"email": "setup@test.com", "token": "wrong-token", "password": "newpass",
|
|
})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
class TestEmailAuth:
|
|
def test_send_link_registered(self, client):
|
|
resp = client.post("/auth/email/send-link", json={"email": "ml@test.com"})
|
|
assert resp.status_code == 200
|
|
# Always returns same message (anti-enumeration)
|
|
assert "If this email" in resp.json()["message"]
|
|
|
|
def test_send_link_unregistered(self, client):
|
|
resp = client.post("/auth/email/send-link", json={"email": "nobody@test.com"})
|
|
assert resp.status_code == 200
|
|
assert "If this email" in resp.json()["message"]
|
|
|
|
def test_verify_invalid_token(self, client):
|
|
resp = client.post("/auth/email/verify", json={
|
|
"email": "ml@test.com", "token": "invalid",
|
|
})
|
|
assert resp.status_code == 401
|
|
|
|
|
|
class TestGoogleOAuth:
|
|
def test_google_login_not_configured(self, client):
|
|
"""Without GOOGLE_CLIENT_ID, should redirect to login with error."""
|
|
resp = client.get("/auth/google/login", follow_redirects=False)
|
|
assert resp.status_code == 302 or resp.status_code == 307
|
|
assert "error" in resp.headers.get("location", "")
|
|
|
|
|
|
class TestCookieAuth:
|
|
def test_web_ui_with_cookie(self, client):
|
|
"""Test that web UI routes accept JWT from cookie."""
|
|
from app.auth.jwt import create_access_token
|
|
from src.db import get_system_db
|
|
from src.repositories.users import UserRepository
|
|
|
|
conn = get_system_db()
|
|
ur = UserRepository(conn)
|
|
# Use existing user
|
|
user = ur.get_by_email("pw@test.com")
|
|
conn.close()
|
|
|
|
token = create_access_token(user["id"], user["email"], user["role"])
|
|
# Set cookie and access dashboard
|
|
client.cookies.set("access_token", token)
|
|
resp = client.get("/dashboard")
|
|
# Should not be 401 — cookie auth works
|
|
assert resp.status_code != 401
|