504dfc52f5
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Login is now by email, not username. username still exists internally (JWT sub claim, audit logs, Jira actor attribution, SSO provisioning all still key off it) but is now always kept equal to email everywhere a user is created or their email changes — never a separately-chosen value. - User.email is now unique + NOT NULL (migration b067 backfills any missing/blank email from username first, so existing rows — notably the seeded admin, which historically had none — never violate it). - /auth/login and the (unused but updated for consistency) authenticate_user() now query by email. - create_user (legacy, unreferenced but kept) and create_user_without_password both derive username from email. - update_user keeps username in sync when email changes, and rejects duplicate emails. - seed.py reads ADMIN_EMAIL (new env var, wired through install.sh and docker-compose.prod.yml) for the initial admin; falls back to an email-shaped ADMIN_USERNAME or a placeholder that's flagged for the operator to fix. - admin_config.py's import bundle now matches/creates users by email, skipping (not crashing on) entries with no email. - sso_service.py always sets username = email for SSO-provisioned users. - LoginPage/auth.ts updated to email input/copy (wire field name stays 'username' — that's the OAuth2PasswordRequestForm spec, not the value).
188 lines
5.9 KiB
Python
188 lines
5.9 KiB
Python
"""Tests for authentication endpoints."""
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import jwt
|
|
import pytest
|
|
|
|
from app.config import settings
|
|
|
|
|
|
def _make_token(username: str, *, expired_seconds_ago: int | None = None) -> str:
|
|
"""Build a raw JWT mirroring ``create_access_token`` with a controllable exp."""
|
|
if expired_seconds_ago is None:
|
|
expire = datetime.now(timezone.utc) + timedelta(minutes=30)
|
|
else:
|
|
expire = datetime.now(timezone.utc) - timedelta(seconds=expired_seconds_ago)
|
|
payload = {"sub": username, "exp": expire, "jti": str(uuid.uuid4())}
|
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
|
|
|
|
|
def test_login_success(client, admin_user):
|
|
"""Test successful login returns a token."""
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "admin@test.com", "password": "admin123"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "access_token" in data
|
|
assert data["token_type"] == "bearer"
|
|
|
|
|
|
def test_login_wrong_password(client, admin_user):
|
|
"""Test login with wrong password returns 400."""
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "admin@test.com", "password": "wrongpassword"},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_login_nonexistent_user(client):
|
|
"""Test login with non-existent user returns 400."""
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "nobody", "password": "password"},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_login_inactive_user(client, db):
|
|
"""Test login with inactive user returns 400."""
|
|
from app.auth import hash_password
|
|
from app.models.user import User
|
|
|
|
user = User(
|
|
username="inactive",
|
|
email="inactive@test.com",
|
|
hashed_password=hash_password("password"),
|
|
role="viewer",
|
|
is_active=False,
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "inactive@test.com", "password": "password"},
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_get_me_with_token(client, admin_user, admin_token):
|
|
"""Test /auth/me returns current user with valid token."""
|
|
response = client.get(
|
|
"/api/v1/auth/me",
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["username"] == "admin"
|
|
assert data["role"] == "admin"
|
|
|
|
|
|
def test_get_me_without_token(client):
|
|
"""Test /auth/me returns 401 without token."""
|
|
response = client.get("/api/v1/auth/me")
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_get_me_invalid_token(client):
|
|
"""Test /auth/me returns 401 with invalid token."""
|
|
response = client.get(
|
|
"/api/v1/auth/me",
|
|
headers={"Authorization": "Bearer invalidtoken"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_logout_revokes_token(client, admin_user):
|
|
"""After logout, the same JWT must be rejected (Redis blacklist).
|
|
|
|
Prefer Authorization over the HttpOnly cookie so logout blacklists the
|
|
same token the client sends on the next request; clear cookies to avoid
|
|
stale jar state across calls.
|
|
"""
|
|
login = client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": "admin@test.com", "password": "admin123"},
|
|
)
|
|
assert login.status_code == 200
|
|
token = login.json()["access_token"]
|
|
|
|
client.cookies.clear()
|
|
out = client.post(
|
|
"/api/v1/auth/logout",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert out.status_code == 200
|
|
|
|
import jwt
|
|
|
|
from app.config import settings
|
|
from app.infrastructure.redis_client import get_redis_blacklist
|
|
|
|
payload = jwt.decode(
|
|
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM],
|
|
)
|
|
jti = payload.get("jti")
|
|
assert jti
|
|
assert get_redis_blacklist().exists(f"blacklist:{jti}")
|
|
|
|
client.cookies.clear()
|
|
assert not len(client.cookies), "cookie jar must be empty to force Authorization Bearer"
|
|
me = client.get(
|
|
"/api/v1/auth/me",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert me.status_code == 401
|
|
assert me.json()["detail"] == "Token has been revoked"
|
|
|
|
|
|
def test_refresh_without_cookie_fails(client):
|
|
response = client.post("/api/v1/auth/refresh")
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_refresh_valid_token_succeeds(client, admin_user):
|
|
token = _make_token("admin")
|
|
client.cookies.set("aegis_token", token)
|
|
response = client.post("/api/v1/auth/refresh")
|
|
assert response.status_code == 200
|
|
assert "access_token" in response.json()
|
|
|
|
|
|
def test_refresh_recently_expired_token_succeeds_within_grace(client, admin_user):
|
|
"""A token that expired moments ago must still be refreshable.
|
|
|
|
This is the core of the refresh-token bug fix: without a grace window,
|
|
`/auth/refresh` decodes with the same strict expiry check as every
|
|
other endpoint, so a 401-triggered refresh attempt always also fails.
|
|
"""
|
|
token = _make_token("admin", expired_seconds_ago=60)
|
|
client.cookies.set("aegis_token", token)
|
|
response = client.post("/api/v1/auth/refresh")
|
|
assert response.status_code == 200
|
|
assert "access_token" in response.json()
|
|
|
|
|
|
def test_refresh_long_expired_token_fails(client, admin_user):
|
|
token = _make_token("admin", expired_seconds_ago=60 * 60)
|
|
client.cookies.set("aegis_token", token)
|
|
response = client.post("/api/v1/auth/refresh")
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_refresh_blacklisted_token_fails_even_if_not_expired(client, admin_user):
|
|
from app.auth import blacklist_token
|
|
|
|
token = _make_token("admin")
|
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
|
blacklist_token(payload["jti"], float(payload["exp"]))
|
|
|
|
client.cookies.set("aegis_token", token)
|
|
response = client.post("/api/v1/auth/refresh")
|
|
assert response.status_code == 403
|