Files
BanGUI/backend/tests/test_repositories/test_settings_and_session.py
Lukas 81f009e323 TASK-022: Hash session tokens in database for security
- 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>
2026-04-26 14:36:21 +02:00

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