Files
core-api/tests/test_scribe.py
T
jpmschweitzerandClaude Sonnet 5 ccdbc44d67 feat(scribe): add the Scribe domain — addresses, send, mailbox, history, find, mark, tag (T-5)
The Office (workspace D-66, D-68, D-73) needs a message bus: an address is
a topic named for an identity, owned by a tenant and a consumer. D-68 puts
it here, in core-api's own database, behind its own auth, following
src/domains/book/.

Migration 006 lands the whole schema by hand (down_revision="005"):
scribe_addresses (owner_tenant + name primary key — no surrogate id, since
no route ever references an address except by name), scribe_messages
(retained; a generated tsvector search column + GIN index on subject/body,
and a GIN index on the topics array for history's lookup), and
scribe_mailbox_entries (one row per recipient address per message, unique
on address+message_id). No data: seeding `jeroen` and `tower-control` is a
deploy step through `register`, not this migration.

Routes, every one behind require_caller("office", "user"), tenant from the
caller (T-4):

- register/list addresses — register is idempotent for the same consumer,
  409 address_taken for another consumer in the tenant; list marks which
  addresses the caller's consumer owns.
- send — `from` is always the caller's own identity, stamped server-side,
  never read from the body; 403 identity_not_owned if that identity is not
  a registered address owned by the caller's consumer; 404 unknown_address
  (naming the missing ones) if any recipient does not exist in the tenant;
  writes one mailbox entry per recipient.
- mailbox / find — the caller's own identity's entries (mailbox: unread
  first; find: full text via websearch_to_tsquery), both requiring the
  identity be owned by the caller's consumer.
- history — the retained messages sent to an address, oldest first;
  reading never consumes or marks, and reads scribe_messages directly
  rather than joining through mailbox entries.
- mark / tag / untag — share one PATCH route; an entry outside the
  caller's tenant, or addressed to an identity the caller isn't reading
  as, answers 404 not_found — indistinguishable from a nonexistent entry.

Every query filters on the caller's tenant (D-73): an address or message
in another tenant answers exactly like one that does not exist, so no id
is ever confirmed across that boundary (test_scribe.py::TestTenantIsolation,
including two tests proving the tenant filter is load-bearing even when
the same address *name* legitimately exists, differently owned, in two
tenants — not just when the caller's own identity is unregistered there).

Messaging only this round: groups, subscriptions, the notification matrix
and delivery are explicitly not built (workspace T-176) — the schema
already carries what `topics`/`tags` need for them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 15:18:52 +02:00

582 lines
24 KiB
Python

"""
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.
"""
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 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"
# =============================================================================
# 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"