Files
core-api/tests/test_scribe.py
T
jpmschweitzerandClaude Sonnet 5 3026b9214f feat(scribe): mailbox takes since_message_id, answers newest_message_id (T-16)
The Office's one polling call, glance, answers "has anything changed
since I last looked?" with counts; live core-api ignored the
parameters it needed, so glance could not ship.

GET /scribe/mailbox now takes since_message_id=N: only entries whose
message number is above N (strictly — not >=), combined with
view/unread/tag the same way they combine with each other today.
message_id, which already bypasses those filters, keeps bypassing
since_message_id too.

The response gains newest_message_id: the caller's newest message
number across its whole mailbox, every view, regardless of any filter
the call applied; null when the mailbox is empty. It exists because
the unread-first sort can return an older unread entry on a limit=1
page while a newer message is already read — the exact shape glance
hits. Computed from a scoped MAX(message_id) query (tenant and
address), never from the page itself, so it cannot read stale or leak
across the tenant/address boundary (D-73).

total's meaning is unchanged: it already counted every filter match
independent of limit (the count query runs against the pre-limit
subquery), so since_message_id just becomes one more filter it counts
against.

Three mutations run and reverted to confirm the new tests actually
cover the behavior: message_id > vs >= broke the boundary test;
computing newest_message_id from the sorted page instead of the whole
mailbox broke the older-unread test; dropping the address filter from
the MAX query broke the address-boundary test.

583 -> 592 tests passing; test database left at head (010).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 00:55:31 +02:00

1266 lines
53 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.
"""
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"]