Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
329 lines
13 KiB
Python
329 lines
13 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_legacy_interaction_language_is_atomic_idempotent_and_owner_scoped(database_url):
|
|
session_id = str(uuid.uuid4())
|
|
owner = _repository(database_url, "language-owner")
|
|
foreign = _repository(database_url, "language-foreign")
|
|
owner.create(_manifest(session_id))
|
|
owner.write_artifact(session_id, "sql_final", "SELECT 'paziente' AS nome")
|
|
with pytest.raises(SessionError, match="Sessione non trovata"):
|
|
foreign.ensure_interaction_language(session_id, "en")
|
|
|
|
barrier = Barrier(2)
|
|
|
|
def pin(language):
|
|
barrier.wait()
|
|
return owner.ensure_interaction_language(session_id, language).manifest.interaction_language
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
results = list(pool.map(pin, ["it", "en"]))
|
|
assert results[0] == results[1]
|
|
saved = owner.get(session_id)
|
|
repeated = owner.ensure_interaction_language(session_id, "fr")
|
|
assert repeated == saved
|
|
assert saved.artifacts["sql_final"] == "SELECT 'paziente' AS nome"
|
|
|
|
|
|
def test_cli_creation_and_resume_use_postgres_language_authority(database_url, monkeypatch, tmp_path):
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
from sqlalchemy.engine import make_url
|
|
from typer.testing import CliRunner
|
|
|
|
from tht.cli import session_cmd
|
|
from tht.config import DatabaseConfig
|
|
|
|
url = make_url(database_url)
|
|
connection = SimpleNamespace(user=url.username, password=url.password, host=url.host,
|
|
port=url.port, database=url.database, sslmode="disable",
|
|
sslrootcert=None)
|
|
cfg = SimpleNamespace(language="it", session_storage=SimpleNamespace(connection=connection),
|
|
database=DatabaseConfig(database="testdb", user="u", password="p",
|
|
schema="public"),
|
|
paths=SimpleNamespace(sessions=tmp_path / "unused"))
|
|
monkeypatch.setattr(session_cmd, "_load_config_or_exit", lambda _: cfg)
|
|
monkeypatch.setenv("THT_PRINCIPAL_ISSUER", "portal")
|
|
monkeypatch.setenv("THT_PRINCIPAL_SUBJECT", "cli-language")
|
|
|
|
def invoke(*args):
|
|
result = CliRunner().invoke(session_cmd.session_app, list(args))
|
|
assert result.exit_code == 0, result.output
|
|
return result.stdout
|
|
|
|
session_id = json.loads(invoke("new", "Pazienti", "--interaction-language", "en", "--json"))["id"]
|
|
invoke("reopen", session_id)
|
|
assert json.loads(invoke("show", session_id, "--json"))["interaction_language"] == "en"
|
|
legacy = _manifest(str(uuid.uuid4()))
|
|
_repository(database_url, "cli-language").create(legacy)
|
|
invoke("reopen", legacy.id)
|
|
cfg.language = "en"
|
|
invoke("reopen", legacy.id)
|
|
assert json.loads(invoke("show", legacy.id, "--json"))["interaction_language"] == "it"
|
|
assert not cfg.paths.sessions.exists()
|
|
|
|
|
|
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)
|