"""Unit tests for auth.models (U1 — V2 schema + backfill).
Covers:
- ``auth_sessions`` table creation (columns + indexes)
- ``auth_meta`` table creation
- ``_SCHEMA_VERSION`` constant value
- ``auth_session_row_to_dict`` field-by-field conversion
- ``_backfill_user_sessions`` one-time migration (V1 → V2)
- ``init_auth_db`` idempotency (subsequent runs are no-ops)
- ``auth_provider`` column default value
- Index presence (verified via ``PRAGMA index_list``)
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
import aiosqlite
import pytest
from agentkit.server.auth.models import (
AuthSessionModel,
UserSessionModel,
_SCHEMA_VERSION,
auth_session_row_to_dict,
init_auth_db,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def fresh_db(tmp_path: Path) -> Path:
"""A brand-new auth DB on a fresh path (no data)."""
db_path = tmp_path / "auth.db"
await init_auth_db(db_path)
return db_path
async def _insert_user(db: aiosqlite.Connection, *, user_id: str | None = None) -> str:
"""Insert a minimal user row and return its id."""
user_id = user_id or str(uuid.uuid4())
now_iso = datetime.now(timezone.utc).isoformat()
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
user_id,
f"user-{user_id[:8]}",
f"{user_id[:8]}@example.com",
"$2b$12$placeholder.hash.placeholder.hash.placeholder.hash",
"member",
1,
0,
0,
now_iso,
now_iso,
),
)
return user_id
async def _insert_user_session(
db: aiosqlite.Connection,
*,
user_id: str,
session_id: str | None = None,
refresh_hash: str | None = None,
device_info: str = "{}",
revoked: bool = False,
) -> str:
"""Insert a V1 ``user_sessions`` row and return its id."""
session_id = session_id or str(uuid.uuid4())
refresh_hash = refresh_hash or uuid.uuid4().hex
now_iso = datetime.now(timezone.utc).isoformat()
await db.execute(
"INSERT INTO user_sessions "
"(id, user_id, refresh_token_hash, device_info, created_at, expires_at, revoked_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
session_id,
user_id,
refresh_hash,
device_info,
now_iso,
now_iso,
now_iso if revoked else None,
),
)
return session_id
async def _list_index_names(db: aiosqlite.Connection, table: str) -> set[str]:
"""Return the set of index names for a table.
Sets ``row_factory`` on the connection so we can address columns by name.
PRAGMA results in aiosqlite come back as plain tuples unless a row factory
is configured.
"""
db.row_factory = aiosqlite.Row
cursor = await db.execute(f"PRAGMA index_list({table})")
rows = await cursor.fetchall()
return {row["name"] for row in rows}
# ---------------------------------------------------------------------------
# _SCHEMA_VERSION
# ---------------------------------------------------------------------------
class TestSchemaVersion:
def test_schema_version_is_v4(self):
"""The current schema version is 4 (V4 adds skill_states table)."""
assert _SCHEMA_VERSION == 4
def test_sqlalchemy_model_table_name(self):
assert AuthSessionModel.__tablename__ == "auth_sessions"
assert UserSessionModel.__tablename__ == "user_sessions"
# ---------------------------------------------------------------------------
# init_auth_db: tables + indexes
# ---------------------------------------------------------------------------
class TestInitAuthDbTables:
async def test_creates_auth_sessions_table(self, fresh_db: Path):
async with aiosqlite.connect(str(fresh_db)) as db:
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='auth_sessions'"
)
row = await cursor.fetchone()
assert row is not None
async def test_creates_auth_meta_table(self, fresh_db: Path):
async with aiosqlite.connect(str(fresh_db)) as db:
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='auth_meta'"
)
row = await cursor.fetchone()
assert row is not None
async def test_creates_user_sessions_table_for_back_compat(self, fresh_db: Path):
async with aiosqlite.connect(str(fresh_db)) as db:
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_sessions'"
)
row = await cursor.fetchone()
assert row is not None
async def test_records_schema_version_in_auth_meta(self, fresh_db: Path):
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT value FROM auth_meta WHERE key='schema_version'")
row = await cursor.fetchone()
assert row is not None
assert row["value"] == str(_SCHEMA_VERSION)
class TestAuthSessionsIndexes:
async def test_user_id_active_index(self, fresh_db: Path):
async with aiosqlite.connect(str(fresh_db)):
pass
async with aiosqlite.connect(str(fresh_db)) as db:
indexes = await _list_index_names(db, "auth_sessions")
assert "idx_auth_sessions_user_id_active" in indexes
async def test_expires_at_index(self, fresh_db: Path):
async with aiosqlite.connect(str(fresh_db)) as db:
indexes = await _list_index_names(db, "auth_sessions")
assert "idx_auth_sessions_expires_at" in indexes
async def test_auth_provider_index(self, fresh_db: Path):
async with aiosqlite.connect(str(fresh_db)) as db:
indexes = await _list_index_names(db, "auth_sessions")
assert "idx_auth_sessions_auth_provider" in indexes
async def test_refresh_token_hash_unique_index(self, fresh_db: Path):
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
# SQLite stores column-level UNIQUE constraints as PRAGMA index_list
# entries with auto-generated names like sqlite_autoindex_
_1.
# The PRAGMA index_info on each autoindex reports the constrained columns.
cursor = await db.execute("PRAGMA index_list(auth_sessions)")
index_entries = await cursor.fetchall()
column_names: set[str] = set()
for entry in index_entries:
col_cursor = await db.execute(f"PRAGMA index_info({entry['name']})")
col_rows = await col_cursor.fetchall()
for col_row in col_rows:
column_names.add(col_row["name"])
assert "refresh_token_hash" in column_names
# ---------------------------------------------------------------------------
# auth_sessions columns
# ---------------------------------------------------------------------------
class TestAuthSessionsColumns:
async def test_required_columns_present(self, fresh_db: Path):
async with aiosqlite.connect(str(fresh_db)) as db:
cursor = await db.execute("PRAGMA table_info(auth_sessions)")
cols = {row[1] for row in await cursor.fetchall()}
required = {
"id",
"user_id",
"refresh_token_hash",
"device_fingerprint",
"device_label",
"ip",
"user_agent",
"auth_provider",
"created_at",
"last_active_at",
"expires_at",
"revoked",
"revoked_reason",
"previous_session_id",
}
assert required.issubset(cols)
async def test_auth_provider_default_is_local(self, fresh_db: Path):
"""Insert a row without auth_provider → column should default to 'local'."""
user_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
sid = str(uuid.uuid4())
await db.execute(
"INSERT INTO auth_sessions "
"(id, user_id, refresh_token_hash, created_at, last_active_at, expires_at, "
" revoked) "
"VALUES (?, ?, ?, ?, ?, ?, 0)",
(
sid,
user_id,
"deadbeef",
"2026-01-01T00:00:00+00:00",
"2026-01-01T00:00:00+00:00",
"2026-12-31T00:00:00+00:00",
),
)
await db.commit()
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT auth_provider FROM auth_sessions WHERE id=?", (sid,))
row = await cursor.fetchone()
assert row is not None
assert row["auth_provider"] == "local"
async def test_revoked_default_is_false(self, fresh_db: Path):
user_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
sid = str(uuid.uuid4())
await db.execute(
"INSERT INTO auth_sessions "
"(id, user_id, refresh_token_hash, created_at, last_active_at, expires_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
sid,
user_id,
"beefdead",
"2026-01-01T00:00:00+00:00",
"2026-01-01T00:00:00+00:00",
"2026-12-31T00:00:00+00:00",
),
)
await db.commit()
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT revoked FROM auth_sessions WHERE id=?", (sid,))
row = await cursor.fetchone()
assert bool(row["revoked"]) is False
# ---------------------------------------------------------------------------
# auth_session_row_to_dict
# ---------------------------------------------------------------------------
class TestAuthSessionRowToDict:
async def test_converts_all_fields(self, fresh_db: Path):
user_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
sid = str(uuid.uuid4())
await db.execute(
"INSERT INTO auth_sessions "
"(id, user_id, refresh_token_hash, device_fingerprint, device_label, ip, "
" user_agent, auth_provider, created_at, last_active_at, expires_at, "
" revoked, revoked_reason, previous_session_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
sid,
user_id,
"abc123",
"fp-xyz",
"macOS Chrome 119",
"10.0.0.1",
"Mozilla/5.0",
"local",
"2026-01-01T00:00:00+00:00",
"2026-06-20T00:00:00+00:00",
"2027-01-01T00:00:00+00:00",
1,
"user_terminated",
"old-sid",
),
)
await db.commit()
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM auth_sessions WHERE id=?", (sid,))
row = await cursor.fetchone()
d = auth_session_row_to_dict(row)
assert d["id"] == sid
assert d["user_id"] == user_id
assert d["device_fingerprint"] == "fp-xyz"
assert d["device_label"] == "macOS Chrome 119"
assert d["ip"] == "10.0.0.1"
assert d["user_agent"] == "Mozilla/5.0"
assert d["auth_provider"] == "local"
assert d["revoked"] is True
assert d["revoked_reason"] == "user_terminated"
assert d["previous_session_id"] == "old-sid"
async def test_normalizes_revoked_to_bool(self, fresh_db: Path):
"""DB stores 0/1; helper should return Python bool."""
user_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
sid = str(uuid.uuid4())
await db.execute(
"INSERT INTO auth_sessions "
"(id, user_id, refresh_token_hash, created_at, last_active_at, expires_at, "
" revoked) "
"VALUES (?, ?, ?, ?, ?, ?, 0)",
(sid, user_id, "x", "t", "t", "t"),
)
await db.commit()
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM auth_sessions WHERE id=?", (sid,))
row = await cursor.fetchone()
d = auth_session_row_to_dict(row)
assert isinstance(d["revoked"], bool)
assert d["revoked"] is False
# ---------------------------------------------------------------------------
# _backfill_user_sessions
# ---------------------------------------------------------------------------
class TestBackfillUserSessions:
async def test_backfills_non_revoked_v1_rows(self, fresh_db: Path):
"""On a fresh V1 install, all non-revoked rows are migrated to auth_sessions."""
user_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
await _insert_user_session(
db,
user_id=user_id,
refresh_hash="hash1",
device_info="{}",
)
await _insert_user_session(
db,
user_id=user_id,
refresh_hash="hash2",
device_info=json.dumps(
{
"fingerprint": "mac-tauri",
"label": "macOS Tauri 1.0",
"ip": "192.168.1.10",
"user_agent": "Tauri/1.0",
}
),
)
await _insert_user_session(
db,
user_id=user_id,
refresh_hash="hash3",
revoked=True,
)
await db.commit()
# Force a backfill by clearing the marker + dropping auth_sessions rows
# (simulating a V1 install upgrading to V2).
await db.execute("DELETE FROM auth_sessions")
await db.execute("DELETE FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'")
await db.commit()
# Re-init to trigger the migration
await init_auth_db(fresh_db)
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM auth_sessions ORDER BY refresh_token_hash")
rows = await cursor.fetchall()
# Only the 2 non-revoked rows should have been backfilled
assert len(rows) == 2
hashes = {row["refresh_token_hash"] for row in rows}
assert hashes == {"hash1", "hash2"}
async def test_backfill_preserves_original_id(self, fresh_db: Path):
"""Backfilled rows reuse the V1 id so legacy clients match."""
user_id = str(uuid.uuid4())
original_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
await _insert_user_session(
db,
user_id=user_id,
session_id=original_id,
refresh_hash="hash-original",
)
await db.execute("DELETE FROM auth_sessions")
await db.execute("DELETE FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'")
await db.commit()
await init_auth_db(fresh_db)
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT id FROM auth_sessions WHERE refresh_token_hash='hash-original'"
)
row = await cursor.fetchone()
assert row is not None
assert row["id"] == original_id
async def test_backfill_does_not_touch_revoked_v1_rows(self, fresh_db: Path):
user_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
await _insert_user_session(
db,
user_id=user_id,
refresh_hash="revoked-hash",
revoked=True,
)
await db.execute("DELETE FROM auth_sessions")
await db.execute("DELETE FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'")
await db.commit()
await init_auth_db(fresh_db)
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT COUNT(*) AS c FROM auth_sessions WHERE refresh_token_hash='revoked-hash'"
)
row = await cursor.fetchone()
assert row["c"] == 0
async def test_backfill_copies_device_info_fields(self, fresh_db: Path):
user_id = str(uuid.uuid4())
device_info = json.dumps(
{
"fingerprint": "win-tauri-abc",
"label": "Windows Tauri 1.0",
"ip": "10.0.0.5",
"user_agent": "Tauri/2.0",
}
)
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
await _insert_user_session(
db,
user_id=user_id,
refresh_hash="hash-with-device",
device_info=device_info,
)
await db.execute("DELETE FROM auth_sessions")
await db.execute("DELETE FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'")
await db.commit()
await init_auth_db(fresh_db)
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT device_fingerprint, device_label, ip, user_agent "
"FROM auth_sessions WHERE refresh_token_hash='hash-with-device'"
)
row = await cursor.fetchone()
assert row["device_fingerprint"] == "win-tauri-abc"
assert row["device_label"] == "Windows Tauri 1.0"
assert row["ip"] == "10.0.0.5"
assert row["user_agent"] == "Tauri/2.0"
async def test_backfill_handles_malformed_device_info(self, fresh_db: Path):
"""Malformed JSON in device_info → fall back to defaults."""
user_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
await _insert_user_session(
db,
user_id=user_id,
refresh_hash="bad-json",
device_info="not-json{",
)
await db.execute("DELETE FROM auth_sessions")
await db.execute("DELETE FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'")
await db.commit()
await init_auth_db(fresh_db)
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT device_fingerprint, device_label FROM auth_sessions "
"WHERE refresh_token_hash='bad-json'"
)
row = await cursor.fetchone()
assert row is not None
assert row["device_fingerprint"] == "unknown"
assert row["device_label"] == "Unknown device"
async def test_backfill_marks_done_in_auth_meta(self, fresh_db: Path):
"""After a backfill, the auth_meta marker is set so it doesn't run again."""
user_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
await _insert_user_session(db, user_id=user_id, refresh_hash="h1")
await db.execute("DELETE FROM auth_sessions")
await db.execute("DELETE FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'")
await db.commit()
await init_auth_db(fresh_db)
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT value FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'"
)
row = await cursor.fetchone()
assert row is not None
assert row["value"] == "done"
async def test_backfill_is_idempotent(self, fresh_db: Path):
"""Running init twice does NOT duplicate auth_sessions rows."""
user_id = str(uuid.uuid4())
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
for i in range(3):
await _insert_user_session(db, user_id=user_id, refresh_hash=f"hash{i}")
await db.execute("DELETE FROM auth_sessions")
await db.execute("DELETE FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'")
await db.commit()
await init_auth_db(fresh_db)
await init_auth_db(fresh_db) # second run should be a no-op
async with aiosqlite.connect(str(fresh_db)) as db:
cursor = await db.execute("SELECT COUNT(*) AS c FROM auth_sessions")
row = await cursor.fetchone()
assert row[0] == 3
async def test_fresh_install_marks_backfill_done_without_rows(self, fresh_db: Path):
"""A fresh V2 install (no V1 rows) still marks the backfill as done."""
# The fresh_db fixture already ran init_auth_db once.
# Re-running should be a no-op (idempotent).
await init_auth_db(fresh_db)
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT value FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'"
)
row = await cursor.fetchone()
assert row is not None
assert row["value"] == "done"
async def test_backfill_preserves_expires_at(self, fresh_db: Path):
user_id = str(uuid.uuid4())
original_exp = "2027-06-20T12:34:56+00:00"
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
await db.execute(
"INSERT INTO user_sessions "
"(id, user_id, refresh_token_hash, device_info, created_at, expires_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
str(uuid.uuid4()),
user_id,
"exp-hash",
"{}",
"2026-01-01T00:00:00+00:00",
original_exp,
),
)
await db.execute("DELETE FROM auth_sessions")
await db.execute("DELETE FROM auth_meta WHERE key='backfill_user_sessions_v1_to_v2'")
await db.commit()
await init_auth_db(fresh_db)
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT expires_at FROM auth_sessions WHERE refresh_token_hash='exp-hash'"
)
row = await cursor.fetchone()
assert row["expires_at"] == original_exp
# ---------------------------------------------------------------------------
# Active-session query pattern (covers the cap-count and list-active paths)
# ---------------------------------------------------------------------------
class TestActiveSessionQueries:
async def test_query_active_sessions_for_user(self, fresh_db: Path):
"""The (user_id, revoked, expires_at) index supports the active-session query."""
user_id = str(uuid.uuid4())
future = "2027-12-31T00:00:00+00:00"
past = "2020-01-01T00:00:00+00:00"
async with aiosqlite.connect(str(fresh_db)) as db:
await _insert_user(db, user_id=user_id)
# 2 active, 1 expired, 1 revoked → 2 should match
for hash_, exp in [("a", future), ("b", future), ("c", past)]:
await db.execute(
"INSERT INTO auth_sessions "
"(id, user_id, refresh_token_hash, created_at, last_active_at, expires_at, "
" revoked) VALUES (?, ?, ?, ?, ?, ?, 0)",
(str(uuid.uuid4()), user_id, hash_, "t", "t", exp),
)
await db.execute(
"INSERT INTO auth_sessions "
"(id, user_id, refresh_token_hash, created_at, last_active_at, expires_at, "
" revoked, revoked_reason) "
"VALUES (?, ?, ?, ?, ?, ?, 1, 'user_terminated')",
(str(uuid.uuid4()), user_id, "d", "t", "t", future),
)
await db.commit()
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT refresh_token_hash FROM auth_sessions "
"WHERE user_id = ? AND revoked = 0 AND expires_at > ?",
(user_id, "2026-06-20T00:00:00+00:00"),
)
rows = await cursor.fetchall()
hashes = {row["refresh_token_hash"] for row in rows}
assert hashes == {"a", "b"}