Files
ThothII/harness/tests/test_postgres_session_repository.py

266 lines
9.8 KiB
Python

import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from threading import Barrier
import pytest
from sqlalchemy import create_engine, text
from testcontainers.postgres import PostgresContainer
from tht.decisions import DecisionInput
from tht.phase import effective_decisions
from tht.session.models import PrincipalContext, SessionManifest
from tht.session.store import SessionError
@pytest.fixture(scope="module")
def database_url():
with PostgresContainer("postgres:16-alpine") as postgres:
yield postgres.get_connection_url()
@pytest.fixture(scope="module", autouse=True)
def migrated(database_url):
from tht.session.postgres_repository import migrate
migrate(database_url)
def _manifest(session_id: str) -> SessionManifest:
return SessionManifest(
id=session_id,
created_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
question="Which patients had an ablation?",
database="testdb",
schema="public",
)
def _repository(database_url, subject: str, *, is_admin: bool = False):
from tht.session.postgres_repository import PostgresSessionRepository
return PostgresSessionRepository(
database_url,
PrincipalContext(issuer="portal", subject=subject, is_admin=is_admin),
)
def test_owner_can_read_own_snapshot_but_not_another_owners(database_url):
alice = _repository(database_url, "alice")
bob = _repository(database_url, "bob")
session_id = str(uuid.uuid4())
alice.create(_manifest(session_id))
alice.write_artifact(session_id, "cte_sql:eligible_patients", "SELECT 1")
alice.append_decisions(
session_id, [DecisionInput(type="sql_approved", subject="phase:7", detail="SELECT 1")]
)
snapshot = alice.get(session_id)
assert snapshot.principal == alice.principal
assert snapshot.artifacts == {"cte_sql:eligible_patients": "SELECT 1"}
assert snapshot.decisions[0].seq == 1
with pytest.raises(SessionError, match="Sessione non trovata"):
bob.get(session_id)
def test_named_decision_is_stale_after_postgres_ledger_reopen(database_url):
repository = _repository(database_url, "phase-owner")
session_id = str(uuid.uuid4())
repository.create(_manifest(session_id))
for phase in range(1, 5):
repository.append_decisions(session_id, [
DecisionInput(type="phase_approved", subject=f"phase:{phase}")
])
repository.append_decisions(session_id, [
DecisionInput(type="cte_approved", subject="named_cte")
])
repository.append_decisions(session_id, [
DecisionInput(type="phase_reopened", subject="phase:3")
])
snapshot = repository.get(session_id)
named = next(item for item in snapshot.decisions if item.subject == "named_cte")
assert named.phase == 5
assert "named_cte" not in {item.subject for item in effective_decisions(snapshot)}
def test_admin_can_read_another_owners_session(database_url):
owner = _repository(database_url, "owner")
admin = _repository(database_url, "admin", is_admin=True)
session_id = str(uuid.uuid4())
owner.create(_manifest(session_id))
assert admin.get(session_id).manifest.id == session_id
def test_owner_admin_and_foreign_principals_have_distinct_mutation_boundaries(database_url):
session_id = str(uuid.uuid4())
alice = _repository(database_url, f"alice-{session_id}")
bob = _repository(database_url, f"bob-{session_id}")
admin = _repository(database_url, f"admin-{session_id}", is_admin=True)
manifest = _manifest(session_id)
alice.create(manifest)
alice.write_artifact(session_id, "question", "Alice's question")
assert [snapshot.manifest.id for snapshot in alice.list()] == [session_id]
assert bob.list() == []
for mutation in (
lambda: bob.write_artifact(session_id, "question", "Bob's overwrite"),
lambda: bob.append_decisions(
session_id, [DecisionInput(type="concept_clarified", subject="bob")]
),
lambda: bob.save_manifest(manifest.model_copy(update={"name": "Bob's rename"})),
lambda: bob.delete(session_id),
):
with pytest.raises(SessionError, match="Sessione non trovata"):
mutation()
# Admin access is an explicit, audited exception to normal owner isolation.
admin.write_artifact(session_id, "question", "Reviewed by admin")
admin.append_decisions(
session_id, [DecisionInput(type="concept_clarified", subject="admin-review")]
)
owner_snapshot = alice.get(session_id)
assert owner_snapshot.artifacts["question"] == "Reviewed by admin"
assert [record.subject for record in owner_snapshot.decisions] == ["admin-review"]
assert admin.get(session_id).manifest.id == session_id
def test_concurrent_postgres_ledger_mutations_keep_every_decision_in_sequence(database_url, monkeypatch):
from tht.session.postgres_repository import PostgresSessionRepository
repository = _repository(database_url, "alice")
session_id = str(uuid.uuid4())
repository.create(_manifest(session_id))
barrier = Barrier(2)
original_lock = PostgresSessionRepository._lock_session
def enter_lock_together(connection, locked_session_id):
if locked_session_id == session_id:
barrier.wait(timeout=5)
original_lock(connection, locked_session_id)
monkeypatch.setattr(
PostgresSessionRepository,
"_lock_session",
staticmethod(enter_lock_together),
)
def append(subject: str):
return repository.append_decisions(
session_id, [DecisionInput(type="concept_clarified", subject=subject)]
)
with ThreadPoolExecutor(max_workers=2) as pool:
first, second = pool.map(append, ("first", "second"))
snapshot = repository.get(session_id)
assert {first[0].seq, second[0].seq} == {1, 2}
assert [record.seq for record in snapshot.decisions] == [1, 2]
assert {record.subject for record in snapshot.decisions} == {"first", "second"}
def test_non_superuser_runtime_login_can_assume_the_restricted_runtime_role(database_url):
admin = create_engine(database_url)
runtime_url = admin.url.set(
username="thoth_sessions_test_login", password="runtime-test-only"
)
try:
with admin.begin() as connection:
connection.exec_driver_sql(
"CREATE ROLE thoth_sessions_test_login LOGIN NOINHERIT PASSWORD 'runtime-test-only'"
)
connection.exec_driver_sql("GRANT thoth_sessions_runtime TO thoth_sessions_test_login")
repository = _repository(
runtime_url.render_as_string(hide_password=False), "runtime-user"
)
session_id = str(uuid.uuid4())
repository.create(_manifest(session_id))
assert repository.get(session_id).manifest.id == session_id
repository.close()
finally:
with admin.begin() as connection:
connection.exec_driver_sql("DROP ROLE IF EXISTS thoth_sessions_test_login")
admin.dispose()
def test_delete_cascades_content_and_leaves_content_free_tombstone(database_url):
repository = _repository(database_url, "alice")
session_id = str(uuid.uuid4())
repository.create(_manifest(session_id))
repository.write_artifact(session_id, "sql_final", "SELECT confidential_value")
repository.append_decisions(
session_id,
[DecisionInput(type="sql_approved", subject="phase:7", detail="SELECT confidential_value")],
)
repository.delete(session_id)
with pytest.raises(SessionError, match="Sessione non trovata"):
repository.get(session_id)
engine = create_engine(database_url)
try:
with engine.connect() as connection:
assert connection.execute(
text("SELECT count(*) FROM thoth_sessions.session_artifacts WHERE session_id = :session_id"),
{"session_id": session_id},
).scalar_one() == 0
assert connection.execute(
text("SELECT count(*) FROM thoth_sessions.review_decisions WHERE session_id = :session_id"),
{"session_id": session_id},
).scalar_one() == 0
columns = connection.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'thoth_sessions' AND table_name = 'audit_log'"
)
).scalars().all()
tombstone = connection.execute(
text(
"SELECT action, session_id, actor_issuer, actor_subject "
"FROM thoth_sessions.audit_log WHERE session_id = :session_id"
),
{"session_id": session_id},
).one()
finally:
engine.dispose()
assert (tombstone[0], str(tombstone[1]), *tombstone[2:]) == (
"session_deleted",
session_id,
"portal",
"alice",
)
assert not {"content", "artifact_content", "detail", "metadata"} & set(columns)
def test_session_schema_does_not_create_or_invoke_embeddings(database_url, monkeypatch):
import tht.session.postgres_repository as repository_module
monkeypatch.setattr(
repository_module,
"_embed",
lambda *_: pytest.fail("session persistence must not invoke embeddings"),
raising=False,
)
repository = _repository(database_url, "alice")
session_id = str(uuid.uuid4())
repository.create(_manifest(session_id))
repository.write_artifact(session_id, "evidence", '{"sources": []}')
engine = create_engine(database_url)
try:
with engine.connect() as connection:
columns = connection.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'thoth_sessions'"
)
).scalars().all()
finally:
engine.dispose()
assert all("embedding" not in column for column in columns)