Replaces a static SPA that shipped an Airtable PAT in the JS bundle.
The new architecture holds all secrets server-side, fronts the app
behind Apache on optical-dev with the shared-vhost split-build pattern,
and is designed for a later Azure AD/MSAL swap-in.
- backend/ FastAPI + uvicorn, local auth (Azure AD stub), Airtable
proxy with TTL cache, Zoho .xlsx/.csv parser, merge
service for utilisation summaries. 28 pytest tests.
- frontend/ React + Vite + TS + Tailwind + Recharts SPA. Login entry
chunk 12.83 KB gzipped; Recharts lazy-loaded. No tokens
or Airtable URLs in the built bundle.
- deploy/ Idempotent deploy.sh (port auto-pick 8200-8299,
.env-persisted) + split-build Apache include template.
- docker-compose.yml pins name: utilisation-dept and binds 127.0.0.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
88 lines
2.5 KiB
Python
88 lines
2.5 KiB
Python
"""Auth + session tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
def _make_client():
|
|
# Ensure local mode + no bypass for these tests.
|
|
os.environ["AUTH_MODE"] = "local"
|
|
os.environ["DEV_AUTH_BYPASS"] = "false"
|
|
from app.config import get_settings
|
|
get_settings.cache_clear()
|
|
# Re-import main so middleware picks up settings.
|
|
import importlib
|
|
import app.main as main_mod
|
|
importlib.reload(main_mod)
|
|
# base_url must be https so the Secure session cookie is sent back.
|
|
return TestClient(main_mod.app, base_url="https://testserver")
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
yield _make_client()
|
|
|
|
|
|
def test_login_happy_path(client):
|
|
r = client.post("/api/auth/login", json={"username": "admin", "password": "admin"})
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert body["username"] == "admin"
|
|
# Session cookie present.
|
|
assert "ud_session" in r.cookies
|
|
|
|
|
|
def test_login_wrong_password(client):
|
|
r = client.post("/api/auth/login", json={"username": "admin", "password": "nope"})
|
|
assert r.status_code == 401
|
|
assert r.json()["detail"] == "Invalid credentials"
|
|
|
|
|
|
def test_me_requires_session(client):
|
|
r = client.get("/api/auth/me")
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_me_after_login(client):
|
|
r = client.post("/api/auth/login", json={"username": "admin", "password": "admin"})
|
|
assert r.status_code == 200
|
|
r2 = client.get("/api/auth/me")
|
|
assert r2.status_code == 200
|
|
assert r2.json()["username"] == "admin"
|
|
|
|
|
|
def test_logout(client):
|
|
client.post("/api/auth/login", json={"username": "admin", "password": "admin"})
|
|
r = client.post("/api/auth/logout")
|
|
assert r.status_code == 204
|
|
|
|
|
|
def test_bypass_mode():
|
|
os.environ["DEV_AUTH_BYPASS"] = "true"
|
|
from app.config import get_settings
|
|
get_settings.cache_clear()
|
|
import importlib
|
|
import app.main as main_mod
|
|
importlib.reload(main_mod)
|
|
c = TestClient(main_mod.app, base_url="https://testserver")
|
|
r = c.get("/api/auth/me")
|
|
assert r.status_code == 200
|
|
assert r.json()["mode"] == "bypass"
|
|
# Restore for any subsequent tests.
|
|
os.environ["DEV_AUTH_BYPASS"] = "false"
|
|
get_settings.cache_clear()
|
|
importlib.reload(main_mod)
|
|
|
|
|
|
def test_login_rate_limit(client):
|
|
# 5/minute → 6th attempt should 429.
|
|
last = None
|
|
for _ in range(6):
|
|
last = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
|
assert last is not None
|
|
assert last.status_code == 429
|