""" Tests for the Scribe domain (dossier core-api T-5 / D-66, D-68, D-73) against `core_api_test`. Follows tests/test_book.py's TestClient pattern: `get_async_session` is overridden to the test database for every test, building a fresh engine/session per call because TestClient drives the ASGI app on a different event loop than pytest-asyncio's fixture setup. """ from unittest.mock import patch import pytest from fastapi.testclient import TestClient from src.domains.auth.callers import _IDENTITY_RE, RESERVED_IDENTITIES from src.domains.scribe.models import ADDRESS_NAME_PATTERN, RESERVED_ADDRESS_NAMES from src.main import app from src.shared.config import get_settings from src.shared.database import get_async_session settings = get_settings() OFFICE_KEY = "test-office-key" TENANT = "jpmschweitzer" OTHER_TENANT = "llm_tester" def test_address_name_rule_matches_callers_identity_rule(): """models.py deliberately duplicates callers.py's identity shape rather than importing its private constants (see models.py's comment) — assert the two never drift apart instead.""" assert ADDRESS_NAME_PATTERN == _IDENTITY_RE.pattern assert RESERVED_ADDRESS_NAMES == RESERVED_IDENTITIES @pytest.fixture(autouse=True) def office_key(): """Every Scribe test authenticates as the office caller unless it says otherwise.""" original = settings.office_service_key settings.office_service_key = OFFICE_KEY yield settings.office_service_key = original @pytest.fixture def client(clean_scribe_tables, test_database_url): from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1) async def _override(): engine = create_async_engine(asyncpg_url, poolclass=NullPool) session_factory = async_sessionmaker(bind=engine, expire_on_commit=False) async with session_factory() as session: yield session await engine.dispose() app.dependency_overrides[get_async_session] = _override yield TestClient(app) app.dependency_overrides.pop(get_async_session, None) def office_headers(identity: str, consumer: str, tenant: str = TENANT) -> dict: return { "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Identity": identity, "X-Office-Consumer": consumer, "X-Office-Tenant": tenant, } def office_headers_no_identity(consumer: str, tenant: str = TENANT) -> dict: """No `X-Office-Identity` at all — core-api T-15's one exemption (an office caller's `feedback` to `tower-control` alone) is reachable only through this shape; every other write still needs the header.""" return { "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Consumer": consumer, "X-Office-Tenant": tenant, } def register(client, identity: str, consumer: str, tenant: str = TENANT): return client.post( "/scribe/addresses", json={"name": identity}, headers=office_headers(identity, consumer, tenant), ) # ============================================================================= # Caller matrix — a representative slice; the full matrix is test_callers.py # and test_book.py's TestCallerMatrixOnBookRoutes already exercises # require_caller itself end to end on a sibling domain. # ============================================================================= class TestCallerMatrixOnScribeRoutes: def test_no_auth_is_401_on_scribe_routes(self, client): from src.domains.auth.oidc import oidc_config oidc_config.configure(enabled=True, issuers=[], audiences=[]) try: response = client.post("/scribe/addresses", json={"name": "jeroen"}) finally: oidc_config.configure(enabled=False, issuers=[], audiences=[]) assert response.status_code == 401 def test_office_write_without_identity_is_400(self, client): response = client.post( "/scribe/addresses", json={"name": "jeroen"}, headers={ "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Consumer": "office-claude", "X-Office-Tenant": TENANT, }, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "identity_required" def test_office_write_without_tenant_is_400(self, client): response = client.post( "/scribe/addresses", json={"name": "jeroen"}, headers={ "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Identity": "jeroen", "X-Office-Consumer": "office-claude", }, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "tenant_required" def test_scheduler_key_is_403_on_scribe_addresses(self, client): settings.scheduler_service_key = "test-scheduler-key" try: response = client.post( "/scribe/addresses", json={"name": "jeroen"}, headers={"Authorization": "Bearer test-scheduler-key"}, ) finally: settings.scheduler_service_key = "" assert response.status_code == 403 assert response.json()["detail"]["code"] == "forbidden" # ============================================================================= # register / list addresses # ============================================================================= class TestRegisterAndListAddresses: def test_register_creates_address_owned_by_caller(self, client): response = register(client, "jeroen", "office-claude") assert response.status_code == 200 data = response.json() assert data["name"] == "jeroen" assert data["owner_consumer"] == "office-claude" assert data["created_by"] == "jeroen" assert data["owned"] is True def test_register_is_idempotent_for_the_same_consumer(self, client): first = register(client, "jeroen", "office-claude") second = register(client, "jeroen", "office-claude") assert first.status_code == 200 assert second.status_code == 200 assert second.json()["owned"] is True def test_register_by_another_consumer_is_409_address_taken(self, client): register(client, "jeroen", "office-claude") response = register(client, "jeroen", "office-gemini") assert response.status_code == 409 assert response.json()["detail"]["code"] == "address_taken" @pytest.mark.parametrize("name", ["book", "office", "scheduler", "tatlock", "group-home"]) def test_register_reserved_or_group_name_is_400(self, client, name): """The registering identity ("jeroen") is valid and distinct from the name being registered, so this exercises the service's own reserved/group- check, not the caller header's identity check.""" response = client.post( "/scribe/addresses", json={"name": name}, headers=office_headers("jeroen", "office-claude"), ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "address_invalid" @pytest.mark.parametrize("name", ["Jeroen", "-abc", "a_b", ""]) def test_register_malformed_name_is_422(self, client, name): """Shape alone is a pydantic concern (422), not a ScribeError (400).""" response = client.post( "/scribe/addresses", json={"name": name}, headers=office_headers("jeroen", "office-claude"), ) assert response.status_code == 422 def test_list_addresses_marks_ownership(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") as_claude = client.get( "/scribe/addresses", headers=office_headers("jeroen", "office-claude") ).json() owned = {a["name"]: a["owned"] for a in as_claude["addresses"]} assert owned == {"jeroen": True, "tower-control": False} def test_list_addresses_is_scoped_to_the_callers_tenant(self, client): register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "jeroen", "office-claude", tenant=OTHER_TENANT) as_other_tenant = client.get( "/scribe/addresses", headers=office_headers("jeroen", "office-claude", OTHER_TENANT) ).json() assert len(as_other_tenant["addresses"]) == 1 # ============================================================================= # The flow (dossier verification #2): register, send, mailbox, mark, # history, find, from shows identity and consumer. # ============================================================================= class TestMessagingFlow: def test_full_flow(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") sent = client.post( "/scribe/messages", json={"to": ["tower-control"], "subject": "status", "body": "all green"}, headers=office_headers("jeroen", "office-claude"), ) assert sent.status_code == 201 message = sent.json() assert message["from_identity"] == "jeroen" assert message["from_consumer"] == "office-claude" assert message["topics"] == ["tower-control"] mailbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert mailbox["total"] == 1 entry = mailbox["entries"][0] assert entry["read_at"] is None assert entry["message"]["id"] == message["id"] assert entry["message"]["body"] == "all green" marked = client.patch( f"/scribe/mailbox/{entry['id']}", json={"read": True}, headers=office_headers("tower-control", "office-gemini"), ) assert marked.status_code == 200 assert marked.json()["read_at"] is not None mailbox_after = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert mailbox_after["entries"][0]["read_at"] is not None history = client.get( "/scribe/addresses/tower-control/history", headers=office_headers("tower-control", "office-gemini"), ).json() assert history["address"] == "tower-control" assert len(history["messages"]) == 1 assert history["messages"][0]["id"] == message["id"] found = client.get( "/scribe/messages", params={"q": "green"}, headers=office_headers("tower-control", "office-gemini"), ).json() assert found["total"] == 1 assert found["entries"][0]["message"]["id"] == message["id"] def test_history_reading_never_marks_or_consumes(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "hi"}, headers=office_headers("jeroen", "office-claude"), ) client.get( "/scribe/addresses/tower-control/history", headers=office_headers("tower-control", "office-gemini"), ) mailbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert mailbox["entries"][0]["read_at"] is None def test_mailbox_lists_unread_first(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") for body in ("first", "second", "third"): client.post( "/scribe/messages", json={"to": ["tower-control"], "body": body}, headers=office_headers("jeroen", "office-claude"), ) mailbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() first_entry_id = mailbox["entries"][0]["id"] client.patch( f"/scribe/mailbox/{first_entry_id}", json={"read": True}, headers=office_headers("tower-control", "office-gemini"), ) mailbox_after = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() read_states = [e["read_at"] is None for e in mailbox_after["entries"]] # unread (True) entries sort before the one read (False) entry assert read_states == sorted(read_states, reverse=True) def test_tag_then_untag_mailbox_entry(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "hi"}, headers=office_headers("jeroen", "office-claude"), ) entry_id = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json()["entries"][0]["id"] tagged = client.patch( f"/scribe/mailbox/{entry_id}", json={"add_tags": ["urgent"]}, headers=office_headers("tower-control", "office-gemini"), ) assert tagged.json()["tags"] == ["urgent"] untagged = client.patch( f"/scribe/mailbox/{entry_id}", json={"remove_tags": ["urgent"]}, headers=office_headers("tower-control", "office-gemini"), ) assert untagged.json()["tags"] == [] # ============================================================================= # send validation # ============================================================================= class TestSendValidation: def test_send_to_unknown_address_is_404_naming_missing_ones(self, client): register(client, "jeroen", "office-claude") response = client.post( "/scribe/messages", json={"to": ["nobody", "tower-control"], "body": "hi"}, headers=office_headers("jeroen", "office-claude"), ) assert response.status_code == 404 detail = response.json()["detail"] assert detail["code"] == "unknown_address" assert set(detail["names"]) == {"nobody", "tower-control"} def test_send_from_identity_not_registered_is_403(self, client): register(client, "tower-control", "office-gemini") response = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "hi"}, headers=office_headers("jeroen", "office-claude"), # jeroen never registered ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "identity_not_owned" def test_cannot_send_as_an_address_another_consumer_owns(self, client): """dossier verification #3: a consumer cannot send *as* an address another consumer owns.""" register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") response = client.post( "/scribe/messages", json={"to": ["jeroen"], "body": "impersonating"}, # office-gemini claiming to BE jeroen, which office-claude owns headers=office_headers("jeroen", "office-gemini"), ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "identity_not_owned" def test_send_with_reply_to_in_another_tenant_is_404(self, client): register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) sent = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "original"}, headers=office_headers("jeroen", "office-claude", tenant=TENANT), ).json() register(client, "jeroen", "office-claude", tenant=OTHER_TENANT) register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT) reply = client.post( "/scribe/messages", json={ "to": ["tower-control"], "body": "cross-tenant reply", "reply_to": sent["id"], }, headers=office_headers("jeroen", "office-claude", tenant=OTHER_TENANT), ) assert reply.status_code == 404 assert reply.json()["detail"]["code"] == "not_found" # ============================================================================= # `feedback` needs no address (core-api T-15 / workspace D-70): the one # shape that may ship with no sender is an office caller's `feedback` to # `tower-control` alone — nothing broader. # ============================================================================= class TestFeedbackWithoutIdentity: def test_feedback_to_tower_control_alone_with_no_identity_succeeds(self, client): register(client, "tower-control", "office-gemini") sent = client.post( "/scribe/messages", json={ "to": ["tower-control"], "body": "the find verb has no limit guard", "tags": ["feedback"], }, headers=office_headers_no_identity("office-claude"), ) assert sent.status_code == 201 message = sent.json() assert message["from_identity"] is None assert message["from_consumer"] == "office-claude" assert message["topics"] == ["tower-control"] assert message["tags"] == ["feedback"] mailbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert mailbox["total"] == 1 assert mailbox["entries"][0]["message"]["from_identity"] is None assert mailbox["entries"][0]["message"]["id"] == message["id"] history = client.get( "/scribe/addresses/tower-control/history", headers=office_headers("tower-control", "office-gemini"), ).json() assert history["messages"][0]["from_identity"] is None found = client.get( "/scribe/messages", params={"q": "limit"}, headers=office_headers("tower-control", "office-gemini"), ).json() assert found["total"] == 1 assert found["entries"][0]["message"]["from_identity"] is None def test_feedback_with_no_identity_still_needs_a_known_tenant(self, client): """No sender is not no tenant (the brief): `_require_tenant` keeps running on this path, ahead of the identity decision.""" response = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]}, headers=office_headers_no_identity("office-claude", tenant="no-such-tenant"), ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "tenant_unknown" def test_feedback_with_no_identity_still_needs_a_granted_consumer(self, client): response = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]}, headers=office_headers_no_identity("office-ungranted"), ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "consumer_not_granted" @pytest.mark.parametrize( "to,tags", [ (["tower-control", "jeroen"], ["feedback"]), # a second recipient (["jeroen"], ["feedback"]), # another recipient, not tower-control (["tower-control"], []), # no feedback tag (["tower-control"], ["urgent"]), # tagged, but not feedback ], ids=[ "second-recipient", "another-recipient", "no-feedback-tag", "tagged-but-not-feedback", ], ) def test_every_broader_shape_with_no_identity_is_still_400(self, client, to, tags): response = client.post( "/scribe/messages", json={"to": to, "body": "x", "tags": tags}, headers=office_headers_no_identity("office-claude"), ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "identity_required" def test_user_kind_caller_gains_nothing_from_the_exemption(self, client): """A user-kind caller's identity is never empty (callers.py always falls back to a name, never ""), so it never reaches the no-identity branch at all: sending this exact shape is unaffected by T-15 and still needs an owned address, same as before. Not 400 identity_required — that code names an office caller resolved with none; this is 403 identity_not_owned, because "jeroen" was never registered under this (consumer-less) user identity.""" register(client, "tower-control", "office-gemini") with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user: mock_get_current_user.return_value = { "preferred_username": "jeroen", "tatlock_user": TENANT, } response = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]}, headers={"Authorization": "Bearer some-jwt"}, ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "identity_not_owned" # ============================================================================= # Isolation (dossier verification #3): the hard tenant boundary, both # directions — every cross-tenant answer is indistinguishable from "does # not exist". # ============================================================================= class TestTenantIsolation: def test_llm_tester_cannot_list_jpmschweitzer_addresses(self, client): register(client, "jeroen", "office-claude", tenant=TENANT) as_other = client.get( "/scribe/addresses", headers=office_headers("jeroen", "office-claude", OTHER_TENANT) ).json() assert as_other["addresses"] == [] def test_llm_tester_cannot_send_to_a_jpmschweitzer_address(self, client): register(client, "tower-control", "office-gemini", tenant=TENANT) # TENANT only register(client, "jeroen", "office-claude", tenant=OTHER_TENANT) response = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "leaking?"}, headers=office_headers("jeroen", "office-claude", OTHER_TENANT), ) # From OTHER_TENANT's point of view, "tower-control" does not exist. assert response.status_code == 404 assert response.json()["detail"]["code"] == "unknown_address" def test_llm_tester_cannot_read_mailbox_of_jpmschweitzer_address(self, client): register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "secret"}, headers=office_headers("jeroen", "office-claude", tenant=TENANT), ) # llm_tester has never registered tower-control, so it cannot even # bind the identity -> 403, indistinguishable from "no such address". response = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini", OTHER_TENANT), ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "identity_not_owned" def test_llm_tester_cannot_see_history_of_a_jpmschweitzer_address(self, client): register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "secret"}, headers=office_headers("jeroen", "office-claude", tenant=TENANT), ) response = client.get( "/scribe/addresses/tower-control/history", headers=office_headers("jeroen", "office-claude", OTHER_TENANT), ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "unknown_address" def test_llm_tester_cannot_find_a_message_of_a_jpmschweitzer_address(self, client): register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "unique-secret-word"}, headers=office_headers("jeroen", "office-claude", tenant=TENANT), ) response = client.get( "/scribe/messages", params={"q": "unique-secret-word"}, headers=office_headers("tower-control", "office-gemini", OTHER_TENANT), ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "identity_not_owned" def test_mark_on_an_entry_in_another_tenant_is_404(self, client): register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "secret"}, headers=office_headers("jeroen", "office-claude", tenant=TENANT), ) entry_id = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini", TENANT) ).json()["entries"][0]["id"] register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT) response = client.patch( f"/scribe/mailbox/{entry_id}", json={"read": True}, headers=office_headers("tower-control", "office-gemini", OTHER_TENANT), ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_mark_on_someone_elses_entry_within_the_same_tenant_is_404(self, client): """Not a tenant crossing, but the same non-disclosure principle: an entry addressed to another identity answers like it doesn't exist, rather than confirming it belongs to someone else.""" register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "secret"}, headers=office_headers("jeroen", "office-claude", tenant=TENANT), ) entry_id = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini", TENANT) ).json()["entries"][0]["id"] response = client.patch( f"/scribe/mailbox/{entry_id}", json={"read": True}, headers=office_headers("jeroen", "office-claude", TENANT), # not tower-control ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_mailbox_does_not_leak_entries_from_another_tenants_same_named_address(self, client): """The identity-ownership precheck alone is not the isolation guarantee: a caller legitimately owns "jeroen" in their own tenant, and a *different* "jeroen" (same name, different owner) exists in another tenant with mail of its own. The mailbox query's own tenant filter is what keeps that mail out — this is what mutation check #1 (dossier) targets.""" register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) client.post( "/scribe/messages", json={"to": ["jeroen"], "body": "for the TENANT jeroen"}, headers=office_headers("tower-control", "office-gemini", tenant=TENANT), ) register(client, "jeroen", "office-claude", tenant=OTHER_TENANT) register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT) client.post( "/scribe/messages", json={"to": ["jeroen"], "body": "for the OTHER_TENANT jeroen"}, headers=office_headers("tower-control", "office-gemini", tenant=OTHER_TENANT), ) mailbox = client.get( "/scribe/mailbox", headers=office_headers("jeroen", "office-claude", TENANT) ).json() assert mailbox["total"] == 1 assert mailbox["entries"][0]["message"]["body"] == "for the TENANT jeroen" def test_find_does_not_leak_messages_from_another_tenants_same_named_address(self, client): """Same concern as above, for `find` (mutation check #1's other half).""" register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) client.post( "/scribe/messages", json={"to": ["jeroen"], "body": "shared-search-term in TENANT"}, headers=office_headers("tower-control", "office-gemini", tenant=TENANT), ) register(client, "jeroen", "office-claude", tenant=OTHER_TENANT) register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT) client.post( "/scribe/messages", json={"to": ["jeroen"], "body": "shared-search-term in OTHER_TENANT"}, headers=office_headers("tower-control", "office-gemini", tenant=OTHER_TENANT), ) found = client.get( "/scribe/messages", params={"q": "shared-search-term"}, headers=office_headers("jeroen", "office-claude", TENANT), ).json() assert found["total"] == 1 assert found["entries"][0]["message"]["body"] == "shared-search-term in TENANT" class TestTenantRegistryEnforcement: """core-api T-8 / workspace D-73: Scribe's own `_require_tenant` now delegates to the tenant registry's one enforcement point, so a Scribe route gains three refusal paths it did not have before, alongside the control that a properly granted, active tenant still succeeds.""" def test_unknown_tenant_is_403(self, client): response = client.get( "/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant="no-such-tenant"), ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "tenant_unknown" def test_suspended_tenant_is_403(self, client, suspended_tenant): response = client.get( "/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant=suspended_tenant), ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "tenant_suspended" def test_consumer_without_a_grant_is_403(self, client): response = client.get( "/scribe/addresses", headers=office_headers("jeroen", "office-ungranted", tenant=TENANT), ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "consumer_not_granted" def test_granted_consumer_in_an_active_tenant_succeeds(self, client): """The control: office-claude, granted in jpmschweitzer (active), still reaches the route.""" response = client.get( "/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant=TENANT) ) assert response.status_code == 200 # ============================================================================= # Archive and trash a mailbox entry (core-api T-13 / D-66, D-70): deletion # is per mailbox entry, never per message — the message stays in `history` # and another recipient's entry is untouched throughout. # ============================================================================= def _send_and_get_entry_id(client, body: str, to: str = "tower-control") -> tuple: """Register the usual pair if needed by the caller, send one message, and return (message, entry_id) for the recipient's own mailbox entry.""" sent = client.post( "/scribe/messages", json={"to": [to], "body": body}, headers=office_headers("jeroen", "office-claude"), ).json() entry_id = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json()["entries"][0]["id"] return sent, entry_id class TestArchiveAndTrash: def test_archive_hides_from_inbox_and_shows_in_archive_view(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") _, entry_id = _send_and_get_entry_id(client, "archive me") archived = client.patch( f"/scribe/mailbox/{entry_id}", json={"archived": True}, headers=office_headers("tower-control", "office-gemini"), ) assert archived.status_code == 200 assert archived.json()["archived_at"] is not None assert archived.json()["trashed_at"] is None inbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert inbox["total"] == 0 archive_view = client.get( "/scribe/mailbox", params={"view": "archive"}, headers=office_headers("tower-control", "office-gemini"), ).json() assert archive_view["total"] == 1 assert archive_view["entries"][0]["id"] == entry_id def test_unarchive_brings_it_back_to_inbox(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") _, entry_id = _send_and_get_entry_id(client, "archive then unarchive") client.patch( f"/scribe/mailbox/{entry_id}", json={"archived": True}, headers=office_headers("tower-control", "office-gemini"), ) unarchived = client.patch( f"/scribe/mailbox/{entry_id}", json={"archived": False}, headers=office_headers("tower-control", "office-gemini"), ) assert unarchived.json()["archived_at"] is None inbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert inbox["total"] == 1 assert inbox["entries"][0]["id"] == entry_id def test_trash_hides_from_inbox_archive_and_find_shows_in_trash(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") _, entry_id = _send_and_get_entry_id(client, "trash-findable-term") trashed = client.patch( f"/scribe/mailbox/{entry_id}", json={"trashed": True}, headers=office_headers("tower-control", "office-gemini"), ) assert trashed.status_code == 200 assert trashed.json()["trashed_at"] is not None inbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert inbox["total"] == 0 archive_view = client.get( "/scribe/mailbox", params={"view": "archive"}, headers=office_headers("tower-control", "office-gemini"), ).json() assert archive_view["total"] == 0 found = client.get( "/scribe/messages", params={"q": "trash-findable-term"}, headers=office_headers("tower-control", "office-gemini"), ).json() assert found["total"] == 0 trash_view = client.get( "/scribe/mailbox", params={"view": "trash"}, headers=office_headers("tower-control", "office-gemini"), ).json() assert trash_view["total"] == 1 assert trash_view["entries"][0]["id"] == entry_id def test_restore_brings_it_back_to_inbox(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") _, entry_id = _send_and_get_entry_id(client, "trash then restore") client.patch( f"/scribe/mailbox/{entry_id}", json={"trashed": True}, headers=office_headers("tower-control", "office-gemini"), ) restored = client.patch( f"/scribe/mailbox/{entry_id}", json={"trashed": False}, headers=office_headers("tower-control", "office-gemini"), ) assert restored.json()["trashed_at"] is None inbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert inbox["total"] == 1 assert inbox["entries"][0]["id"] == entry_id def test_message_stays_in_history_throughout_archive_and_trash(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") message, entry_id = _send_and_get_entry_id(client, "history survives") for payload in ({"archived": True}, {"trashed": True}): client.patch( f"/scribe/mailbox/{entry_id}", json=payload, headers=office_headers("tower-control", "office-gemini"), ) history = client.get( "/scribe/addresses/tower-control/history", headers=office_headers("tower-control", "office-gemini"), ).json() assert [m["id"] for m in history["messages"]] == [message["id"]] def test_trashing_one_recipients_entry_leaves_the_others_untouched(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") register(client, "jeroen-2", "office-claude") client.post( "/scribe/messages", json={"to": ["tower-control", "jeroen-2"], "body": "broadcast"}, headers=office_headers("jeroen", "office-claude"), ) tower_entry_id = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json()["entries"][0]["id"] client.patch( f"/scribe/mailbox/{tower_entry_id}", json={"trashed": True}, headers=office_headers("tower-control", "office-gemini"), ) other_inbox = client.get( "/scribe/mailbox", headers=office_headers("jeroen-2", "office-claude") ).json() assert other_inbox["total"] == 1 assert other_inbox["entries"][0]["trashed_at"] is None # ============================================================================= # Look an entry up by message number (core-api T-9): zero or one, in any # view, no paging required. # ============================================================================= class TestMailboxMessageIdLookup: def test_message_id_finds_the_entry_in_any_view(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") message, entry_id = _send_and_get_entry_id(client, "locate me") client.patch( f"/scribe/mailbox/{entry_id}", json={"trashed": True}, headers=office_headers("tower-control", "office-gemini"), ) found = client.get( "/scribe/mailbox", params={"message_id": message["id"]}, headers=office_headers("tower-control", "office-gemini"), ).json() assert found["total"] == 1 assert found["entries"][0]["id"] == entry_id assert found["entries"][0]["trashed_at"] is not None def test_message_id_answers_nothing_for_a_message_not_in_their_mailbox(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") sent = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "not jeroen's"}, headers=office_headers("jeroen", "office-claude"), ).json() found = client.get( "/scribe/mailbox", params={"message_id": sent["id"]}, headers=office_headers("jeroen", "office-claude"), ).json() assert found["total"] == 0 assert found["entries"] == [] def test_message_id_answers_nothing_across_tenants(self, client): register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) sent = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "tenant-scoped"}, headers=office_headers("jeroen", "office-claude", tenant=TENANT), ).json() register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT) found = client.get( "/scribe/mailbox", params={"message_id": sent["id"]}, headers=office_headers("tower-control", "office-gemini", OTHER_TENANT), ).json() assert found["total"] == 0 def test_message_id_finds_a_read_entry_past_the_first_page_in_one_call(self, client): """dossier verification #4: today's listing sorts unread-first, then by id descending, behind a single before_id cursor. Seed one old entry that stays unread (so it sorts first regardless of its low id) ahead of 51 read ones (one more than the default page size of 50) — the oldest read entry (lowest id among them) sorts last, past the first page. `message_id` answers it in one call, with no `before_id` paging.""" register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") old_unread = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "old and unread"}, headers=office_headers("jeroen", "office-claude"), ).json() read_message_ids = [] for i in range(51): sent = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": f"read {i}"}, headers=office_headers("jeroen", "office-claude"), ).json() read_message_ids.append(sent["id"]) all_entries = client.get( "/scribe/mailbox", params={"view": "all", "limit": 200}, headers=office_headers("tower-control", "office-gemini"), ).json()["entries"] for entry in all_entries: if entry["message"]["id"] != old_unread["id"]: client.patch( f"/scribe/mailbox/{entry['id']}", json={"read": True}, headers=office_headers("tower-control", "office-gemini"), ) # The oldest (lowest-id) read message: last among the reads when # sorted id-descending, so page 1 (the default limit of 50) never # reaches it — plain listing would need a second before_id call. target_message_id = read_message_ids[0] page_one = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert target_message_id not in {e["message"]["id"] for e in page_one["entries"]} found = client.get( "/scribe/mailbox", params={"message_id": target_message_id}, headers=office_headers("tower-control", "office-gemini"), ).json() assert found["total"] == 1 assert found["entries"][0]["message"]["id"] == target_message_id # ============================================================================= # since_message_id and newest_message_id (core-api T-16): the Office's one # polling call, `glance`, answers "has anything changed since I last # looked?" with these. # ============================================================================= class TestSinceMessageId: def test_since_message_id_returns_only_entries_above_it(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") ids = [] for body in ("one", "two", "three"): sent = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": body}, headers=office_headers("jeroen", "office-claude"), ).json() ids.append(sent["id"]) mailbox = client.get( "/scribe/mailbox", params={"since_message_id": ids[0]}, headers=office_headers("tower-control", "office-gemini"), ).json() returned_ids = {e["message"]["id"] for e in mailbox["entries"]} assert returned_ids == {ids[1], ids[2]} assert mailbox["total"] == 2 def test_since_message_id_boundary_excludes_the_named_message(self, client): """dossier mutation check #1: `>=` would include message N itself; `since_message_id=N` means strictly above N.""" register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") sent = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "the boundary message"}, headers=office_headers("jeroen", "office-claude"), ).json() mailbox = client.get( "/scribe/mailbox", params={"since_message_id": sent["id"]}, headers=office_headers("tower-control", "office-gemini"), ).json() assert mailbox["entries"] == [] assert mailbox["total"] == 0 def test_since_message_id_combines_with_unread_and_a_view(self, client): register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") first = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "first, will be read"}, headers=office_headers("jeroen", "office-claude"), ).json() second = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "second, stays unread"}, headers=office_headers("jeroen", "office-claude"), ).json() first_entry_id = client.get( "/scribe/mailbox", params={"message_id": first["id"]}, headers=office_headers("tower-control", "office-gemini"), ).json()["entries"][0]["id"] client.patch( f"/scribe/mailbox/{first_entry_id}", json={"read": True}, headers=office_headers("tower-control", "office-gemini"), ) mailbox = client.get( "/scribe/mailbox", params={"since_message_id": 0, "unread": True, "view": "inbox"}, headers=office_headers("tower-control", "office-gemini"), ).json() returned_ids = {e["message"]["id"] for e in mailbox["entries"]} assert returned_ids == {second["id"]} def test_total_counts_every_match_independent_of_limit(self, client): """dossier verification #2: `unread=true&limit=1` gives the unread count in one row via `total`.""" register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") for body in ("a", "b", "c"): client.post( "/scribe/messages", json={"to": ["tower-control"], "body": body}, headers=office_headers("jeroen", "office-claude"), ) mailbox = client.get( "/scribe/mailbox", params={"unread": True, "limit": 1}, headers=office_headers("tower-control", "office-gemini"), ).json() assert len(mailbox["entries"]) == 1 assert mailbox["total"] == 3 def test_message_id_lookup_ignores_since_message_id(self, client): """`message_id` already bypasses view/unread/tag; `since_message_id` keeps that behavior (dossier build item #1).""" register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") sent = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "direct lookup"}, headers=office_headers("jeroen", "office-claude"), ).json() found = client.get( "/scribe/mailbox", params={"message_id": sent["id"], "since_message_id": sent["id"]}, headers=office_headers("tower-control", "office-gemini"), ).json() assert found["total"] == 1 assert found["entries"][0]["message"]["id"] == sent["id"] class TestNewestMessageId: def test_newest_message_id_is_true_newest_when_an_older_message_is_unread(self, client): """dossier core-api T-16, the exact case the Office hit: the unread-first sort makes `limit=1` return an older unread message when a newer one is already read. `newest_message_id` must answer with the newer one regardless (dossier mutation check #2).""" register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") older = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "older, stays unread"}, headers=office_headers("jeroen", "office-claude"), ).json() newer = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "newer, gets read"}, headers=office_headers("jeroen", "office-claude"), ).json() newer_entry_id = client.get( "/scribe/mailbox", params={"message_id": newer["id"]}, headers=office_headers("tower-control", "office-gemini"), ).json()["entries"][0]["id"] client.patch( f"/scribe/mailbox/{newer_entry_id}", json={"read": True}, headers=office_headers("tower-control", "office-gemini"), ) page = client.get( "/scribe/mailbox", params={"limit": 1}, headers=office_headers("tower-control", "office-gemini"), ).json() # unread-first sort: the older, still-unread message leads page one — # the condition newest_message_id exists to see past. assert page["entries"][0]["message"]["id"] == older["id"] assert page["newest_message_id"] == newer["id"] def test_newest_message_id_is_null_for_an_empty_mailbox(self, client): register(client, "tower-control", "office-gemini") mailbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert mailbox["entries"] == [] assert mailbox["newest_message_id"] is None def test_newest_message_id_never_crosses_an_address_boundary(self, client): """dossier mutation check #3: dropping the address filter would let a later message delivered to a different address in the same tenant — a higher message number that never reaches this identity's mailbox — leak into its newest_message_id.""" register(client, "jeroen", "office-claude") register(client, "tower-control", "office-gemini") register(client, "ops", "office-claude") mine = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "mine"}, headers=office_headers("jeroen", "office-claude"), ).json() client.post( "/scribe/messages", json={"to": ["ops"], "body": "not mine, later and higher id"}, headers=office_headers("jeroen", "office-claude"), ) mailbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini") ).json() assert mailbox["newest_message_id"] == mine["id"] def test_newest_message_id_never_crosses_a_tenant_boundary(self, client): """Same concern as the address boundary above, across tenants (D-73's hard boundary).""" register(client, "jeroen", "office-claude", tenant=TENANT) register(client, "tower-control", "office-gemini", tenant=TENANT) mine = client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "for the TENANT tower-control"}, headers=office_headers("jeroen", "office-claude", tenant=TENANT), ).json() register(client, "jeroen", "office-claude", tenant=OTHER_TENANT) register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT) client.post( "/scribe/messages", json={"to": ["tower-control"], "body": "for the OTHER_TENANT tower-control, later/higher id"}, headers=office_headers("jeroen", "office-claude", tenant=OTHER_TENANT), ) mailbox = client.get( "/scribe/mailbox", headers=office_headers("tower-control", "office-gemini", TENANT), ).json() assert mailbox["newest_message_id"] == mine["id"]