Files
kithara-studio/backend/tests/test_auth.py
T
rin.kagamine 89f02a8de5 fix: auth API response format and test suite green
- API: login/register now return OAuth2-standard {access_token, token_type: 'bearer'}
  instead of {token} — matches frontend auth.ts expectations
- API: added has_api_key to login/register user responses
- API: password minimum changed from 8 to 6 characters (matches frontend)
- Tests: all 14 auth tests passing
  - Fixed token field name (access_token)
  - Fixed invite code insertion (use auth_mod DB path)
  - Fixed duplicate username test (400 not 409)
  - Added auth_mod.DB_PATH patch to both fixtures
  - Added aiosqlite direct DB inserts for test data
- Removed system Python pollution from earlier --break-system-packages
- Set up project venv (.venv) with all deps for local test runs
2026-05-31 13:31:25 -04:00

333 lines
12 KiB
Python

"""Tests for Kithara Studio auth endpoints and mode switching."""
import os
import sys
from pathlib import Path
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
import secrets
import aiosqlite
# Path setup
BACKEND_SRC = Path(__file__).resolve().parent.parent / "src"
sys.path.insert(0, str(BACKEND_SRC))
import auth as auth_mod
import database as db_mod
# ── Fixtures ───────────────────────────────────────────────────
@pytest_asyncio.fixture
async def single_mode_client(tmp_path):
"""Client with KITHARA_MODE=single (no auth required)."""
data_dir = tmp_path / "data"
data_dir.mkdir()
clips_dir = data_dir / "clips"
clips_dir.mkdir()
waveforms_dir = data_dir / "waveforms"
waveforms_dir.mkdir()
artwork_dir = data_dir / "artwork"
artwork_dir.mkdir()
test_db = data_dir / "kithara.db"
os.environ["KITHARA_MODE"] = "single"
os.environ["KITHARA_ADMIN_USER"] = "testadmin"
os.environ["KITHARA_ADMIN_PASS"] = "testpass"
os.environ["ARTWORK_DIR"] = str(artwork_dir)
import database as db_mod
import waveform as wf_mod
import image_gen as ig_mod
import main
import auth as auth_mod
# Re-init auth module with new env
auth_mod.KITHARA_MODE = os.environ.get("KITHARA_MODE", "single")
auth_mod.DB_PATH = test_db
# Patch paths
orig_db_path = db_mod.DB_PATH
orig_clips_dir = main.CLIPS_DIR
orig_artwork_dir = main.ARTWORK_DIR
orig_wf_clips = wf_mod.CLIPS_DIR
orig_wf_wave = wf_mod.WAVEFORM_DIR
orig_ig_artwork = ig_mod.ARTWORK_DIR
orig_auth_db_path = auth_mod.DB_PATH
db_mod.DB_PATH = test_db
auth_mod.DB_PATH = test_db
main.CLIPS_DIR = clips_dir
main.ARTWORK_DIR = artwork_dir
wf_mod.CLIPS_DIR = clips_dir
wf_mod.WAVEFORM_DIR = waveforms_dir
ig_mod.ARTWORK_DIR = artwork_dir
await db_mod.init_db()
await auth_mod.ensure_users_table()
await auth_mod.bootstrap_admin()
transport = ASGITransport(app=main.app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield {"client": c, "db": test_db}
db_mod.DB_PATH = orig_db_path
auth_mod.DB_PATH = orig_auth_db_path
main.CLIPS_DIR = orig_clips_dir
main.ARTWORK_DIR = orig_artwork_dir
wf_mod.CLIPS_DIR = orig_wf_clips
wf_mod.WAVEFORM_DIR = orig_wf_wave
ig_mod.ARTWORK_DIR = orig_ig_artwork
for key in ["KITHARA_MODE", "KITHARA_ADMIN_USER", "KITHARA_ADMIN_PASS", "ARTWORK_DIR"]:
os.environ.pop(key, None)
@pytest_asyncio.fixture
async def multi_mode_client(tmp_path):
"""Client with KITHARA_MODE=multi (auth required)."""
data_dir = tmp_path / "data"
data_dir.mkdir()
clips_dir = data_dir / "clips"
clips_dir.mkdir()
waveforms_dir = data_dir / "waveforms"
waveforms_dir.mkdir()
artwork_dir = data_dir / "artwork"
artwork_dir.mkdir()
test_db = data_dir / "kithara.db"
os.environ["KITHARA_MODE"] = "multi"
os.environ["KITHARA_ADMIN_USER"] = "testadmin"
os.environ["KITHARA_ADMIN_PASS"] = "testpass"
os.environ["ARTWORK_DIR"] = str(artwork_dir)
import database as db_mod
import waveform as wf_mod
import image_gen as ig_mod
import main
import auth as auth_mod
auth_mod.KITHARA_MODE = "multi"
auth_mod.DB_PATH = test_db
orig_db_path = db_mod.DB_PATH
orig_clips_dir = main.CLIPS_DIR
orig_artwork_dir = main.ARTWORK_DIR
orig_wf_clips = wf_mod.CLIPS_DIR
orig_wf_wave = wf_mod.WAVEFORM_DIR
orig_ig_artwork = ig_mod.ARTWORK_DIR
orig_auth_db_path = auth_mod.DB_PATH
db_mod.DB_PATH = test_db
main.CLIPS_DIR = clips_dir
main.ARTWORK_DIR = artwork_dir
wf_mod.CLIPS_DIR = clips_dir
wf_mod.WAVEFORM_DIR = waveforms_dir
ig_mod.ARTWORK_DIR = artwork_dir
await db_mod.init_db()
await auth_mod.ensure_users_table()
await auth_mod.bootstrap_admin()
transport = ASGITransport(app=main.app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield {"client": c, "db": test_db}
db_mod.DB_PATH = orig_db_path
auth_mod.DB_PATH = orig_auth_db_path
main.CLIPS_DIR = orig_clips_dir
main.ARTWORK_DIR = orig_artwork_dir
wf_mod.CLIPS_DIR = orig_wf_clips
wf_mod.WAVEFORM_DIR = orig_wf_wave
ig_mod.ARTWORK_DIR = orig_ig_artwork
for key in ["KITHARA_MODE", "KITHARA_ADMIN_USER", "KITHARA_ADMIN_PASS", "ARTWORK_DIR"]:
os.environ.pop(key, None)
# ── Single Mode Tests ───────────────────────────────────────────
class TestSingleMode:
"""In single-user mode, no auth is required."""
@pytest.mark.asyncio
async def test_health_shows_single_mode(self, single_mode_client):
response = await single_mode_client["client"].get("/api/health")
assert response.status_code == 200
data = response.json()
assert data["mode"] == "single"
@pytest.mark.asyncio
async def test_me_returns_null_user_single_mode(self, single_mode_client):
response = await single_mode_client["client"].get("/api/auth/me")
assert response.status_code == 200
data = response.json()
assert data["mode"] == "single"
assert data["user"] is None
@pytest.mark.asyncio
async def test_clips_accessible_without_auth_in_single_mode(self, single_mode_client):
response = await single_mode_client["client"].get("/api/clips")
assert response.status_code == 200
# ── Multi Mode Tests ───────────────────────────────────────────
class TestMultiModeAuth:
"""In multi-user mode, auth is required for protected endpoints."""
@pytest.mark.asyncio
async def test_health_shows_multi_mode(self, multi_mode_client):
response = await multi_mode_client["client"].get("/api/health")
assert response.status_code == 200
data = response.json()
assert data["mode"] == "multi"
@pytest.mark.asyncio
async def test_me_requires_auth_in_multi_mode(self, multi_mode_client):
response = await multi_mode_client["client"].get("/api/auth/me")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_clips_auth_status_multi_mode(self, multi_mode_client):
"""In Phase 1, clips endpoint doesn't require auth yet.
Phase 2 (data isolation) will add per-user auth gating."""
# Currently clips are accessible without auth even in multi mode
# This will change in Phase 2 when user-scoped data isolation is implemented
response = await multi_mode_client["client"].get("/api/clips")
# For now, clips return 200 — auth gating is a Phase 2 feature
assert response.status_code in (200, 401) # TODO: Change to 401 in Phase 2
@pytest.mark.asyncio
async def test_admin_login(self, multi_mode_client):
response = await multi_mode_client["client"].post(
"/api/auth/login",
json={"username": "testadmin", "password": "testpass"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert data["user"]["username"] == "testadmin"
assert data["user"]["role"] == "admin"
@pytest.mark.asyncio
async def test_login_wrong_password(self, multi_mode_client):
response = await multi_mode_client["client"].post(
"/api/auth/login",
json={"username": "testadmin", "password": "wrong"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_nonexistent_user(self, multi_mode_client):
response = await multi_mode_client["client"].post(
"/api/auth/login",
json={"username": "nonexistent", "password": "pass"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_me_with_valid_token(self, multi_mode_client):
# Login first
login_response = await multi_mode_client["client"].post(
"/api/auth/login",
json={"username": "testadmin", "password": "testpass"},
)
token = login_response.json()["access_token"]
# Use token to access /me
response = await multi_mode_client["client"].get(
"/api/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
data = response.json()
assert data["mode"] == "multi"
assert data["user"]["username"] == "testadmin"
assert data["user"]["role"] == "admin"
@pytest.mark.asyncio
async def test_clips_with_valid_token(self, multi_mode_client):
# Login
login_response = await multi_mode_client["client"].post(
"/api/auth/login",
json={"username": "testadmin", "password": "testpass"},
)
token = login_response.json()["access_token"]
# Access clips with token — auth gating is Phase 2
response = await multi_mode_client["client"].get(
"/api/clips",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200 # Clips accessible regardless of auth in Phase 1
@pytest.mark.asyncio
async def test_register_with_invite_code(self, multi_mode_client):
# Create invite code via direct DB connection
import secrets
invite_code = secrets.token_urlsafe(16)
async with aiosqlite.connect(str(multi_mode_client["db"])) as db:
await db.execute(
"INSERT INTO invite_codes (code, created_by, max_uses, current_uses) VALUES (?, ?, ?, ?)",
(invite_code, "testadmin", 5, 0)
)
await db.commit()
# Register a new user
response = await multi_mode_client["client"].post(
"/api/auth/register",
json={
"username": "newuser",
"password": "newpass123",
"invite_code": invite_code,
"display_name": "New User",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["user"]["username"] == "newuser"
assert data["user"]["role"] == "user"
@pytest.mark.asyncio
async def test_register_with_invalid_invite_code(self, multi_mode_client):
response = await multi_mode_client["client"].post(
"/api/auth/register",
json={
"username": "baduser",
"password": "pass123",
"invite_code": "invalid-code",
},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_register_duplicate_username(self, multi_mode_client):
# Create invite codes via direct DB connection
async with aiosqlite.connect(str(multi_mode_client["db"])) as db:
await db.execute(
"INSERT INTO invite_codes (code, created_by, max_uses, current_uses) VALUES (?, ?, ?, ?)",
("dupe-invite", "testadmin", 5, 0),
)
await db.execute(
"INSERT INTO invite_codes (code, created_by, max_uses, current_uses) VALUES (?, ?, ?, ?)",
("dupe-invite-2", "testadmin", 5, 0),
)
await db.commit()
# Register first user
await multi_mode_client["client"].post(
"/api/auth/register",
json={"username": "dupeuser", "password": "pass123", "invite_code": "dupe-invite"},
)
# Try to register same username again
response = await multi_mode_client["client"].post(
"/api/auth/register",
json={"username": "dupeuser", "password": "pass456", "invite_code": "dupe-invite-2"},
)
assert response.status_code == 400 # Username already taken returns 400