- Store session tokens as one-way SHA256 hashes instead of plaintext - Hash tokens on write (create_session) and on read (get_session, delete_session) - Add migration to drop plaintext sessions table and recreate with token_hash column - Update Session model: token field still contains raw token for signing - Add test to verify tokens are hashed in database, not plaintext - Update Architekture.md to document session token hashing - Update Backend-Development.md with implementation pattern and best practices Prevents direct session token hijacking if database file is exposed to attacker. If plaintext DB was readable, sessions are invalidated by the migration anyway. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
144 lines
5.4 KiB
Python
144 lines
5.4 KiB
Python
"""Tests for settings_repo and session_repo."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import aiosqlite
|
|
import pytest
|
|
|
|
from app.db import init_db
|
|
from app.repositories import session_repo, settings_repo
|
|
|
|
|
|
@pytest.fixture
|
|
async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc]
|
|
"""Provide an initialised aiosqlite connection."""
|
|
conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "repo_test.db"))
|
|
conn.row_factory = aiosqlite.Row
|
|
await init_db(conn)
|
|
yield conn
|
|
await conn.close()
|
|
|
|
|
|
class TestSettingsRepo:
|
|
async def test_get_missing_key_returns_none(
|
|
self, db: aiosqlite.Connection
|
|
) -> None:
|
|
"""get_setting returns None for a key that does not exist."""
|
|
result = await settings_repo.get_setting(db, "nonexistent")
|
|
assert result is None
|
|
|
|
async def test_set_and_get_round_trip(self, db: aiosqlite.Connection) -> None:
|
|
"""set_setting persists a value retrievable by get_setting."""
|
|
await settings_repo.set_setting(db, "my_key", "my_value")
|
|
result = await settings_repo.get_setting(db, "my_key")
|
|
assert result == "my_value"
|
|
|
|
async def test_set_overwrites_existing_value(
|
|
self, db: aiosqlite.Connection
|
|
) -> None:
|
|
"""set_setting overwrites an existing key with the new value."""
|
|
await settings_repo.set_setting(db, "key", "first")
|
|
await settings_repo.set_setting(db, "key", "second")
|
|
result = await settings_repo.get_setting(db, "key")
|
|
assert result == "second"
|
|
|
|
async def test_delete_removes_key(self, db: aiosqlite.Connection) -> None:
|
|
"""delete_setting removes an existing key."""
|
|
await settings_repo.set_setting(db, "to_delete", "value")
|
|
await settings_repo.delete_setting(db, "to_delete")
|
|
result = await settings_repo.get_setting(db, "to_delete")
|
|
assert result is None
|
|
|
|
async def test_get_all_settings_returns_dict(
|
|
self, db: aiosqlite.Connection
|
|
) -> None:
|
|
"""get_all_settings returns a dict of all stored key-value pairs."""
|
|
await settings_repo.set_setting(db, "k1", "v1")
|
|
await settings_repo.set_setting(db, "k2", "v2")
|
|
all_s = await settings_repo.get_all_settings(db)
|
|
assert all_s["k1"] == "v1"
|
|
assert all_s["k2"] == "v2"
|
|
|
|
|
|
class TestSessionRepo:
|
|
async def test_create_and_get_session(self, db: aiosqlite.Connection) -> None:
|
|
"""create_session stores a session retrievable by get_session."""
|
|
session = await session_repo.create_session(
|
|
db,
|
|
token="abc123",
|
|
created_at="2025-01-01T00:00:00+00:00",
|
|
expires_at="2025-01-01T01:00:00+00:00",
|
|
)
|
|
assert session.token == "abc123"
|
|
|
|
stored = await session_repo.get_session(db, "abc123")
|
|
assert stored is not None
|
|
assert stored.token == "abc123"
|
|
|
|
async def test_get_missing_session_returns_none(
|
|
self, db: aiosqlite.Connection
|
|
) -> None:
|
|
"""get_session returns None for a token that does not exist."""
|
|
result = await session_repo.get_session(db, "no_such_token")
|
|
assert result is None
|
|
|
|
async def test_delete_session_removes_it(self, db: aiosqlite.Connection) -> None:
|
|
"""delete_session removes the session from the database."""
|
|
await session_repo.create_session(
|
|
db,
|
|
token="xyz",
|
|
created_at="2025-01-01T00:00:00+00:00",
|
|
expires_at="2025-01-01T01:00:00+00:00",
|
|
)
|
|
await session_repo.delete_session(db, "xyz")
|
|
result = await session_repo.get_session(db, "xyz")
|
|
assert result is None
|
|
|
|
async def test_delete_expired_sessions(self, db: aiosqlite.Connection) -> None:
|
|
"""delete_expired_sessions removes sessions past their expiry time."""
|
|
await session_repo.create_session(
|
|
db,
|
|
token="expired",
|
|
created_at="2020-01-01T00:00:00+00:00",
|
|
expires_at="2020-01-01T01:00:00+00:00",
|
|
)
|
|
await session_repo.create_session(
|
|
db,
|
|
token="valid",
|
|
created_at="2099-01-01T00:00:00+00:00",
|
|
expires_at="2099-01-01T01:00:00+00:00",
|
|
)
|
|
deleted = await session_repo.delete_expired_sessions(
|
|
db, "2025-01-01T00:00:00+00:00"
|
|
)
|
|
assert deleted == 1
|
|
assert await session_repo.get_session(db, "expired") is None
|
|
assert await session_repo.get_session(db, "valid") is not None
|
|
|
|
async def test_tokens_are_hashed_in_database(
|
|
self, db: aiosqlite.Connection
|
|
) -> None:
|
|
"""Verify that tokens are stored as hashes, not plaintext."""
|
|
import hashlib
|
|
|
|
token = "plaintext_token_12345"
|
|
await session_repo.create_session(
|
|
db,
|
|
token=token,
|
|
created_at="2025-01-01T00:00:00+00:00",
|
|
expires_at="2025-01-01T01:00:00+00:00",
|
|
)
|
|
|
|
# Query the database directly to verify the token is hashed.
|
|
async with db.execute("SELECT token_hash FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.encode()).hexdigest(),)) as cursor:
|
|
row = await cursor.fetchone()
|
|
assert row is not None
|
|
assert row[0] == hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
# The plaintext token should not be in the database.
|
|
async with db.execute("SELECT * FROM sessions WHERE token_hash = ?", (token,)) as cursor:
|
|
row = await cursor.fetchone()
|
|
assert row is None
|