workspace D-70 ("`feedback` needs no address"): the failure channel must
not depend on the thing most likely to break. `scribe_messages.from_identity`
becomes nullable (migration 010); `POST /scribe/messages` accepts a missing
`X-Office-Identity` for exactly one shape — an office caller, `to` is
`["tower-control"]` alone, and `tags` contains `feedback` — stored with
`from_identity` null and `from_consumer` the caller's consumer. Every other
send without an identity is still 400 `identity_required`, enforced in
ScribeService.send_message rather than the caller dependency, since the
decision needs the request body. `MessageResponse.from_identity` is now
optional so mailbox/history/find/message_id answer such a message with a
null sender. Migration 010's downgrade refuses outright while any
sender-less row exists rather than inventing one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1046 lines
43 KiB
Python
1046 lines
43 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
|