diff --git a/backend/test/auth.test.ts b/backend/test/auth.test.ts index ffd77c9c..8285eb47 100644 --- a/backend/test/auth.test.ts +++ b/backend/test/auth.test.ts @@ -51,6 +51,20 @@ test("upstream mode accepts only normalized proxy principal headers", async () = }); }); +test("upstream mode rejects legacy client identity headers without proxy principal fields", async () => { + const app = Fastify(); + app.addHook("preHandler", authPreHandler("upstream")); + app.get("/me", async (req) => getPrincipal(req)); + + for (const headers of [ + { "x-authenticated-user": "mallory" }, + { "x-mock-user": "mallory" }, + { "x-authenticated-user": "mallory", "x-mock-user": "mallory" }, + ]) { + expect((await app.inject({ method: "GET", url: "/me", headers })).statusCode).toBe(401); + } +}); + test("local identity expands tilde homes and restores private POSIX permissions", () => { expect(expandLocalHome("~/thoth-test", "/home/tester")).toBe("/home/tester/thoth-test"); expect(expandLocalHome("~", "/home/tester")).toBe("/home/tester"); diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 8416b28c..96b6c719 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -87,6 +87,41 @@ test("session listing permits all scope only to admins", async () => { expect(seen).toEqual([false, true]); }); +test("A, B, and admin requests preserve owner isolation through session route mutations", async () => { + const owners = new Map([["a", "alice"], ["b", "bob"]]); + const closed: Array<{ id: string; subject: string }> = []; + const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), { + thtRunner: { + withPrincipal: (principal: any) => ({ + sessionList: async () => [...owners] + .filter(([, owner]) => principal.isAdmin || owner === principal.subject) + .map(([id, owner]) => ({ id, author: owner })), + sessionShow: async (id: string) => + (principal.isAdmin || owners.get(id) === principal.subject) ? { id, status: "open" } : null, + closeSession: async (id: string) => { closed.push({ id, subject: principal.subject }); }, + }), + } as any, + mgr: { get: () => undefined } as any, + hub: { clear: () => {} } as any, + getSettings: () => ({ workspace: "w" }) as any, + }); + const bobHeaders = { ...aliceHeaders, "x-thoth-principal-subject": "bob" }; + const adminHeaders = { ...aliceHeaders, "x-thoth-principal-subject": "admin", "x-thoth-is-admin": "1" }; + + expect((await app.inject({ method: "GET", url: "/sessions", headers: aliceHeaders })).json()) + .toEqual([{ id: "a", author: "alice" }]); + expect((await app.inject({ method: "GET", url: "/sessions", headers: bobHeaders })).json()) + .toEqual([{ id: "b", author: "bob" }]); + expect((await app.inject({ method: "GET", url: "/sessions?scope=all", headers: adminHeaders })).json()) + .toEqual([{ id: "a", author: "alice" }, { id: "b", author: "bob" }]); + + expect((await app.inject({ method: "POST", url: "/sessions/a/close", headers: bobHeaders })).statusCode) + .toBe(404); + expect((await app.inject({ method: "POST", url: "/sessions/a/close", headers: adminHeaders })).statusCode) + .toBe(200); + expect(closed).toEqual([{ id: "a", subject: "admin" }]); +}); + test("new sessions are created through the authenticated principal, not a client owner field", async () => { let principal: any; const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), { @@ -1411,6 +1446,24 @@ test("POST /sessions readiness failure returns one fixed public message without expect(createdCalled).toBe(false); }); +test("POST /sessions returns storage 503 before creating a Pi runtime when session persistence fails", async () => { + let piCreated = false; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { + ollamaEnsure: async () => ({ ok: true }), + sessionNew: async () => { throw new Error("database unavailable"); }, + } as any, + getSettings: () => ({ workspace: "psd" }) as any, + mgr: { createFor: () => { piCreated = true; throw new Error("must not spawn"); } } as any, + }); + + const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); + + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ error: "session storage is unavailable" }); + expect(piCreated).toBe(false); +}); + test("POST /sessions proceeds when ollamaEnsure succeeds", async () => { let ensureWs: string | undefined; const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { diff --git a/harness/tests/test_postgres_session_repository.py b/harness/tests/test_postgres_session_repository.py index af3cf6cc..b46b4a70 100644 --- a/harness/tests/test_postgres_session_repository.py +++ b/harness/tests/test_postgres_session_repository.py @@ -1,5 +1,7 @@ import uuid +from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime +from threading import Barrier import pytest from sqlalchemy import create_engine, text @@ -92,6 +94,74 @@ def test_admin_can_read_another_owners_session(database_url): 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( diff --git a/harness/tests/test_session_repository.py b/harness/tests/test_session_repository.py index 9afc93d6..0ca5b343 100644 --- a/harness/tests/test_session_repository.py +++ b/harness/tests/test_session_repository.py @@ -5,7 +5,7 @@ import pytest from tht.config import DatabaseConfig, load_config from tht.decisions import DecisionInput from tht.session.filesystem_repository import FilesystemSessionRepository -from tht.session.models import PrincipalContext, SessionManifest +from tht.session.models import PrincipalContext, SessionManifest, local_principal from tht.session.repository import build_session_repository, resolve_principal from tht.session.store import SessionError from tht.session.store import create_session @@ -105,6 +105,25 @@ def test_filesystem_repository_keeps_preferences_per_principal(tmp_path): assert bob.get_preferences() == {} +def test_two_local_homes_have_independent_identities_sessions_and_preferences(tmp_path): + alice_home = tmp_path / "alice-home" + bob_home = tmp_path / "bob-home" + alice = FilesystemSessionRepository(alice_home, "demo", local_principal(alice_home)) + bob = FilesystemSessionRepository(bob_home, "demo", local_principal(bob_home)) + session_id = str(uuid.uuid4()) + + alice.create(_manifest(session_id)) + alice.set_preferences({"model": "glm"}) + + assert alice.principal.subject != bob.principal.subject + assert (alice.root / session_id / "session_manifest.yaml").exists() + assert not (bob.root / session_id).exists() + assert bob.list() == [] + assert bob.get_preferences() == {} + with pytest.raises(SessionError, match="Sessione non trovata"): + bob.get(session_id) + + @pytest.mark.parametrize("session_id", ["2026-01-01-000000-test", "2026-01-01-000000-x", "s1"]) def test_filesystem_repository_reads_safe_legacy_session_ids(tmp_path, session_id): repository = FilesystemSessionRepository( diff --git a/harness/tht/session/postgres_repository.py b/harness/tht/session/postgres_repository.py index 2171c446..e998f5ea 100644 --- a/harness/tht/session/postgres_repository.py +++ b/harness/tht/session/postgres_repository.py @@ -285,7 +285,9 @@ class PostgresSessionRepository: ids = [row[0] for row in connection.execute( text("SELECT id FROM thoth_sessions.sessions ORDER BY created_at DESC") ).all()] - return [self.get(session_id) for session_id in ids] + # psycopg2 materializes PostgreSQL UUID columns as ``uuid.UUID`` objects, + # while the repository boundary intentionally accepts canonical UUIDv4 text. + return [self.get(str(session_id)) for session_id in ids] def save_manifest(self, manifest: SessionManifest) -> SessionSnapshot: self._require_uuid4(manifest.id)