"""Tests for LocalAuthProvider (U11 — concrete Local implementation).""" from __future__ import annotations import uuid from datetime import datetime, timezone from pathlib import Path import aiosqlite import pytest from agentkit.server.auth.models import init_auth_db from agentkit.server.auth.password import hash_password from agentkit.server.auth.providers import LocalAuthProvider from agentkit.server.auth.providers.exceptions import InvalidCredentials from agentkit.server.auth.providers.user import User # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture async def auth_db_with_users(tmp_path: Path) -> dict: """Create a fresh auth DB with two users: one active, one inactive. Returns a dict with user info + the db path. """ db_path = tmp_path / "auth.db" await init_auth_db(db_path) now_iso = datetime.now(timezone.utc).isoformat() active_id = str(uuid.uuid4()) inactive_id = str(uuid.uuid4()) active_pw = "correct-horse-battery-staple" inactive_pw = "disabled-user-pw" async with aiosqlite.connect(str(db_path)) as db: await db.execute( "INSERT INTO users " "(id, username, email, password_hash, role, is_active, " " is_terminal_authorized, is_server_terminal_authorized, " " created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( active_id, "alice", "alice@example.com", hash_password(active_pw), "member", 1, 0, 0, now_iso, now_iso, ), ) await db.execute( "INSERT INTO users " "(id, username, email, password_hash, role, is_active, " " is_terminal_authorized, is_server_terminal_authorized, " " created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( inactive_id, "bob_inactive", "bob@example.com", hash_password(inactive_pw), "member", 0, 0, 0, now_iso, now_iso, ), ) await db.commit() return { "db_path": db_path, "active": { "id": active_id, "username": "alice", "password": active_pw, "email": "alice@example.com", "role": "member", }, "inactive": { "id": inactive_id, "username": "bob_inactive", "password": inactive_pw, }, } @pytest.fixture def provider(auth_db_with_users: dict) -> LocalAuthProvider: return LocalAuthProvider(db_path=auth_db_with_users["db_path"]) # --------------------------------------------------------------------------- # authenticate # --------------------------------------------------------------------------- class TestAuthenticate: async def test_valid_credentials_returns_user( self, provider: LocalAuthProvider, auth_db_with_users: dict ): user = await provider.authenticate( username=auth_db_with_users["active"]["username"], password=auth_db_with_users["active"]["password"], ) assert isinstance(user, User) assert user.id == auth_db_with_users["active"]["id"] assert user.username == "alice" assert user.email == "alice@example.com" assert user.role == "member" assert user.is_active is True async def test_wrong_password_raises_invalid_credentials( self, provider: LocalAuthProvider, auth_db_with_users: dict ): with pytest.raises(InvalidCredentials): await provider.authenticate( username=auth_db_with_users["active"]["username"], password="definitely-not-the-password", ) async def test_unknown_user_raises_invalid_credentials(self, provider: LocalAuthProvider): with pytest.raises(InvalidCredentials): await provider.authenticate(username="nonexistent", password="anything") async def test_inactive_user_raises_invalid_credentials( self, provider: LocalAuthProvider, auth_db_with_users: dict ): with pytest.raises(InvalidCredentials): await provider.authenticate( username=auth_db_with_users["inactive"]["username"], password=auth_db_with_users["inactive"]["password"], ) async def test_error_message_does_not_leak_username_existence( self, provider: LocalAuthProvider, auth_db_with_users: dict ): """Wrong-password and unknown-user errors must have the same message.""" try: await provider.authenticate( username=auth_db_with_users["active"]["username"], password="wrong", ) except InvalidCredentials as e1: wrong_pw_msg = str(e1) try: await provider.authenticate(username="nobody-here", password="x") except InvalidCredentials as e2: unknown_msg = str(e2) assert wrong_pw_msg == unknown_msg # --------------------------------------------------------------------------- # get_user_by_id # --------------------------------------------------------------------------- class TestGetUserById: async def test_returns_user_for_active_id( self, provider: LocalAuthProvider, auth_db_with_users: dict ): user = await provider.get_user_by_id(auth_db_with_users["active"]["id"]) assert user is not None assert user.id == auth_db_with_users["active"]["id"] assert user.username == "alice" async def test_returns_none_for_inactive_user( self, provider: LocalAuthProvider, auth_db_with_users: dict ): user = await provider.get_user_by_id(auth_db_with_users["inactive"]["id"]) assert user is None async def test_returns_none_for_unknown_id(self, provider: LocalAuthProvider): user = await provider.get_user_by_id(str(uuid.uuid4())) assert user is None # --------------------------------------------------------------------------- # sync_user_attributes # --------------------------------------------------------------------------- class TestSyncUserAttributes: async def test_is_noop(self, provider: LocalAuthProvider, auth_db_with_users: dict): """Local provider has no upstream to sync from — must succeed with no effect.""" result = await provider.sync_user_attributes(auth_db_with_users["active"]["id"]) assert result is None async def test_noop_does_not_throw_for_unknown_user(self, provider: LocalAuthProvider): """sync_user_attributes is fire-and-forget — no existence check.""" # Should NOT raise even though the id doesn't exist await provider.sync_user_attributes(str(uuid.uuid4())) # --------------------------------------------------------------------------- # revoke_user # --------------------------------------------------------------------------- class TestRevokeUser: async def test_sets_is_active_to_zero( self, provider: LocalAuthProvider, auth_db_with_users: dict, tmp_path: Path ): await provider.revoke_user(auth_db_with_users["active"]["id"]) async with aiosqlite.connect(str(auth_db_with_users["db_path"])) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT is_active FROM users WHERE id = ?", (auth_db_with_users["active"]["id"],), ) row = await cursor.fetchone() assert bool(row["is_active"]) is False async def test_revoked_user_can_no_longer_authenticate( self, provider: LocalAuthProvider, auth_db_with_users: dict ): await provider.revoke_user(auth_db_with_users["active"]["id"]) with pytest.raises(InvalidCredentials): await provider.authenticate( username=auth_db_with_users["active"]["username"], password=auth_db_with_users["active"]["password"], ) async def test_revoke_unknown_user_does_not_raise(self, provider: LocalAuthProvider): """``UPDATE ... WHERE id = ?`` with no match is a no-op, not an error.""" await provider.revoke_user(str(uuid.uuid4())) # --------------------------------------------------------------------------- # default db_path # --------------------------------------------------------------------------- class TestDefaultDbPath: def test_default_db_path_uses_models_default(self, tmp_path: Path, monkeypatch): """If no db_path is given, the provider should use the module default. The default may resolve to a path that does not exist on a test machine — we only assert that the property returns a Path, not that the file exists. """ monkeypatch.setenv("AGENTKIT_AUTH_DB", str(tmp_path / "default.db")) provider = LocalAuthProvider() assert isinstance(provider.db_path, Path) assert str(provider.db_path) == str(tmp_path / "default.db")