A tenant has been an unchecked string since T-4/T-7: the Office's X-Office-Tenant header or a user JWT's tatlock_user claim, with Book and Scribe each running their own _require_tenant that checked only presence. A typo'd claim opened a fresh, empty namespace rather than being refused. src/domains/tenants/ (controller/models/schemas/service) adds tenants and tenant_consumers (migration 007, down_revision="006"): tenants.id is the slug a tatlock tenant carries, kind (household|person|test) and status (active|suspended) CHECK-constrained; tenant_consumers is the grant pairing a consumer to a tenant. Migration 007 seeds the two tenants in use today — jpmschweitzer (household, "Jeroen") and llm_tester (test, "Playground") — each granted to office-claude, the only Office consumer configured in Authentik. Every existing owner_tenant column (book_records, book_tenant_counters, scribe_addresses, scribe_messages, scribe_mailbox_entries — five, grepped and confirmed) now carries a foreign key to tenants.id, ON DELETE RESTRICT, at both the migration and the ORM model. TenantsService.require_tenant is the one enforcement point: no tenant is 400 tenant_required (unchanged); an unknown tenant is 403 tenant_unknown; a suspended one is 403 tenant_suspended; an Office caller whose consumer holds no grant in that tenant is 403 consumer_not_granted. One query — a LEFT JOIN against the grant — so presence, status and the grant are all read in a single round trip. Book's and Scribe's own _require_tenant now delegate to it, translating TenantError to their own exception type so every call site and each controller's single except clause keep working unchanged. Read-only this round: GET /tenants/me answers the caller's own tenant and its granted consumers (the cheap "am I allowed here" probe); GET /tenants answers every tenant for a tatlock/scheduler caller, or a list of one for a tenant-bearing caller — another tenant's existence is never revealed. No write routes; provisioning a tenant is workspace T-195. Tests: tests/test_tenants.py and tests/test_tenants_migration.py (new); a TestTenantRegistryEnforcement class added to both tests/test_book.py and tests/test_scribe.py proving all four cases (unknown, suspended, ungranted-consumer, and the control) on a live Book and Scribe route, not only on /tenants itself. tests/conftest.py gains a tenant_registry schema fixture and a suspended_tenant fixture (inserted and deleted per test; no FK references it); book_schema and scribe_schema each independently grant a second, test-environment-only consumer (office-gemini) in both seeded tenants, since test_book.py and test_scribe.py have used it as a stand-in second consumer since before this grant check existed — the migration itself still seeds only office-claude, matching the dossier. Mutation-checked: dropping the grant check fails the three consumer_not_granted tests (200 instead of 403); dropping the status check fails the three tenant_suspended tests; making Book bypass the helper fails its unknown-tenant test (200 instead of 403). All three restored and the full suite re-verified green (508 + 35 = 543 passed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
758 lines
31 KiB
Python
758 lines
31 KiB
Python
"""
|
|
Tests for the Book domain (T-1 / D-68, D-70, T-7) against `core_api_test`.
|
|
|
|
`get_async_session` is overridden to the test database for every test here
|
|
— the Book routes list `caller` before `session` in their dependencies, but
|
|
overriding is what actually guarantees the suite never opens a connection
|
|
to production regardless of FastAPI's dependency resolution order.
|
|
"""
|
|
import asyncio
|
|
import uuid
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from src.domains.auth.oidc import oidc_config
|
|
from src.main import app
|
|
from src.shared.config import get_settings
|
|
from src.shared.database import get_async_session
|
|
|
|
settings = get_settings()
|
|
|
|
OTHER_TENANT = "llm_tester"
|
|
|
|
OFFICE_KEY = "test-office-key"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def office_key():
|
|
"""Every Book 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_book_tables, test_database_url):
|
|
"""
|
|
A TestClient whose DB dependency is overridden to core_api_test.
|
|
|
|
The override must build its own engine/session per call rather than
|
|
reuse `clean_book_tables`'s already-open one: TestClient drives the
|
|
ASGI app from a different event loop than the one pytest-asyncio ran
|
|
the fixture's setup in, and an asyncpg connection is not portable
|
|
across loops — reusing it here raised "Future ... attached to a
|
|
different loop" and then hung. Building fresh per call keeps
|
|
everything inside whichever loop is actually running the request.
|
|
"""
|
|
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)
|
|
|
|
|
|
VALID_CONSUMER = "office-claude"
|
|
VALID_TENANT = "jpmschweitzer"
|
|
|
|
|
|
def office_headers(
|
|
identity: str = "jeroen",
|
|
consumer: str = VALID_CONSUMER,
|
|
tenant: str = VALID_TENANT,
|
|
) -> dict:
|
|
return {
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Identity": identity,
|
|
"X-Office-Consumer": consumer,
|
|
"X-Office-Tenant": tenant,
|
|
}
|
|
|
|
|
|
class TestCallerMatrixOnBookRoutes:
|
|
"""Dossier verification #3, the Book-specific half."""
|
|
|
|
def test_no_auth_is_401_on_book_routes(self, client):
|
|
oidc_config.configure(enabled=True, issuers=[], audiences=[])
|
|
try:
|
|
response = client.post("/book/records", json={"body": "hi"})
|
|
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(
|
|
"/book/records",
|
|
json={"body": "hi"},
|
|
headers={
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Consumer": VALID_CONSUMER,
|
|
"X-Office-Tenant": VALID_TENANT,
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "identity_required"
|
|
|
|
def test_office_write_without_consumer_is_400(self, client):
|
|
response = client.post(
|
|
"/book/records",
|
|
json={"body": "hi"},
|
|
headers={
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Identity": "jeroen",
|
|
"X-Office-Tenant": VALID_TENANT,
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "consumer_required"
|
|
|
|
def test_office_write_without_tenant_is_400(self, client):
|
|
response = client.post(
|
|
"/book/records",
|
|
json={"body": "hi"},
|
|
headers={
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Identity": "jeroen",
|
|
"X-Office-Consumer": VALID_CONSUMER,
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "tenant_required"
|
|
|
|
def test_office_read_without_tenant_is_400(self, client):
|
|
"""The tenant scopes reads too — a list call with no tenant is 400,
|
|
even though list carries no identity_required."""
|
|
response = client.get(
|
|
"/book/records",
|
|
headers={
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Consumer": VALID_CONSUMER,
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "tenant_required"
|
|
|
|
def test_office_malformed_consumer_is_400(self, client):
|
|
response = client.get(
|
|
"/book/records",
|
|
headers={
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Consumer": "Not Valid",
|
|
"X-Office-Tenant": VALID_TENANT,
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "consumer_invalid"
|
|
|
|
def test_office_malformed_tenant_is_400(self, client):
|
|
response = client.get(
|
|
"/book/records",
|
|
headers={
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Consumer": VALID_CONSUMER,
|
|
"X-Office-Tenant": "Not Valid",
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "tenant_invalid"
|
|
|
|
def test_scheduler_key_is_403_on_book_records(self, client):
|
|
settings.scheduler_service_key = "test-scheduler-key"
|
|
try:
|
|
response = client.post(
|
|
"/book/records",
|
|
json={"body": "hi"},
|
|
headers={"Authorization": "Bearer test-scheduler-key"},
|
|
)
|
|
finally:
|
|
settings.scheduler_service_key = ""
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"]["code"] == "forbidden"
|
|
|
|
def test_malformed_identity_is_400(self, client):
|
|
response = client.post(
|
|
"/book/records",
|
|
json={"body": "hi"},
|
|
headers={
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Identity": "Not Valid",
|
|
"X-Office-Consumer": VALID_CONSUMER,
|
|
"X-Office-Tenant": VALID_TENANT,
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "identity_invalid"
|
|
|
|
|
|
class TestAddShowListFind:
|
|
def test_add_note_defaults_to_personal_scope_and_inbox_free(self, client):
|
|
response = client.post(
|
|
"/book/records", json={"body": "buy milk"}, headers=office_headers()
|
|
)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["kind"] == "note"
|
|
assert data["status"] == "active"
|
|
assert data["scope"] == "personal"
|
|
assert data["created_by"] == "jeroen"
|
|
|
|
def test_add_rejects_bad_status_for_kind(self, client):
|
|
response = client.post(
|
|
"/book/records",
|
|
json={"kind": "task", "body": "x", "status": "settled"},
|
|
headers=office_headers(),
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "invalid_status"
|
|
|
|
def test_every_record_carries_exactly_one_of_work_or_personal(self, client):
|
|
default_scope = client.post(
|
|
"/book/records", json={"body": "default scope"}, headers=office_headers()
|
|
).json()["scope"]
|
|
assert default_scope in ("work", "personal")
|
|
|
|
work_scope = client.post(
|
|
"/book/records",
|
|
json={"body": "work item", "scope": "work"},
|
|
headers=office_headers(),
|
|
).json()["scope"]
|
|
assert work_scope == "work"
|
|
|
|
rejected = client.post(
|
|
"/book/records",
|
|
json={"body": "bad scope", "scope": "both"},
|
|
headers=office_headers(),
|
|
)
|
|
assert rejected.status_code == 422 # pydantic Literal rejects a third value
|
|
|
|
def test_show_returns_record_tags_links_and_backlinks(self, client):
|
|
parent = client.post(
|
|
"/book/records", json={"kind": "task", "body": "project"}, headers=office_headers()
|
|
).json()
|
|
child = client.post(
|
|
"/book/records",
|
|
json={"kind": "task", "body": "subtask", "parent": parent["id"], "tags": ["chore"]},
|
|
headers=office_headers(),
|
|
).json()
|
|
|
|
child_shown = client.get(f"/book/records/{child['id']}", headers=office_headers()).json()
|
|
assert child_shown["tags"] == ["chore"]
|
|
assert len(child_shown["links"]) == 1
|
|
assert child_shown["links"][0]["relation"] == "parent"
|
|
assert child_shown["links"][0]["to_id"] == parent["id"]
|
|
|
|
parent_shown = client.get(f"/book/records/{parent['id']}", headers=office_headers()).json()
|
|
assert len(parent_shown["backlinks"]) == 1
|
|
assert parent_shown["backlinks"][0]["from_id"] == child["id"]
|
|
|
|
def test_show_missing_record_is_404(self, client):
|
|
response = client.get(f"/book/records/{uuid.uuid4()}", headers=office_headers())
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"]["code"] == "not_found"
|
|
|
|
def test_list_filters_by_scope(self, client):
|
|
client.post("/book/records", json={"body": "a", "scope": "work"}, headers=office_headers())
|
|
client.post("/book/records", json={"body": "b", "scope": "personal"}, headers=office_headers())
|
|
|
|
work_only = client.get("/book/records", params={"scope": "work"}, headers=office_headers()).json()
|
|
assert work_only["total"] == 1
|
|
assert work_only["records"][0]["scope"] == "work"
|
|
|
|
def test_find_full_text_matches_a_note_and_a_task(self, client):
|
|
"""Dossier verification #4: a note and a task, both found by full-text."""
|
|
note = client.post(
|
|
"/book/records",
|
|
json={"kind": "note", "title": "Weekend plans", "body": "visit the garden centre"},
|
|
headers=office_headers(),
|
|
).json()
|
|
task = client.post(
|
|
"/book/records",
|
|
json={"kind": "task", "body": "renew the car insurance"},
|
|
headers=office_headers(),
|
|
).json()
|
|
|
|
found_note_by_body = client.get(
|
|
"/book/records/find", params={"q": "garden"}, headers=office_headers()
|
|
).json()
|
|
assert found_note_by_body["total"] == 1
|
|
assert found_note_by_body["records"][0]["id"] == note["id"]
|
|
|
|
found_note_by_title = client.get(
|
|
"/book/records/find", params={"q": "weekend"}, headers=office_headers()
|
|
).json()
|
|
assert found_note_by_title["total"] == 1
|
|
assert found_note_by_title["records"][0]["id"] == note["id"]
|
|
|
|
found_task = client.get(
|
|
"/book/records/find", params={"q": "insurance"}, headers=office_headers()
|
|
).json()
|
|
assert found_task["total"] == 1
|
|
assert found_task["records"][0]["id"] == task["id"]
|
|
assert found_task["records"][0]["kind"] == "task"
|
|
|
|
|
|
class TestChange:
|
|
def test_change_updates_only_given_fields(self, client):
|
|
record = client.post(
|
|
"/book/records", json={"kind": "task", "body": "water the plants"}, headers=office_headers()
|
|
).json()
|
|
|
|
changed = client.patch(
|
|
f"/book/records/{record['id']}",
|
|
json={"priority": 1},
|
|
headers=office_headers(),
|
|
).json()
|
|
assert changed["priority"] == 1
|
|
assert changed["body"] == "water the plants"
|
|
|
|
def test_change_without_identity_is_400(self, client):
|
|
record = client.post(
|
|
"/book/records", json={"body": "x"}, headers=office_headers()
|
|
).json()
|
|
response = client.patch(
|
|
f"/book/records/{record['id']}",
|
|
json={"priority": 1},
|
|
headers={
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Consumer": VALID_CONSUMER,
|
|
"X-Office-Tenant": VALID_TENANT,
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "identity_required"
|
|
|
|
|
|
class TestClose:
|
|
def test_close_task_done(self, client):
|
|
task = client.post(
|
|
"/book/records", json={"kind": "task", "body": "mow the lawn"}, headers=office_headers()
|
|
).json()
|
|
closed = client.post(
|
|
f"/book/records/{task['id']}/close", json={"outcome": "done"}, headers=office_headers()
|
|
).json()
|
|
assert closed["status"] == "done"
|
|
|
|
def test_close_question_via_resolves_sets_settled(self, client):
|
|
question = client.post(
|
|
"/book/records",
|
|
json={"kind": "question", "body": "what colour for the fence?"},
|
|
headers=office_headers(),
|
|
).json()
|
|
decision = client.post(
|
|
"/book/records",
|
|
json={"kind": "decision", "body": "fence will be green"},
|
|
headers=office_headers(),
|
|
).json()
|
|
|
|
closed = client.post(
|
|
f"/book/records/{question['id']}/close",
|
|
json={"resolved_by": decision["id"]},
|
|
headers=office_headers(),
|
|
).json()
|
|
assert closed["status"] == "settled"
|
|
|
|
shown = client.get(f"/book/records/{question['id']}", headers=office_headers()).json()
|
|
assert any(b["relation"] == "resolves" and b["from_id"] == decision["id"] for b in shown["backlinks"])
|
|
|
|
def test_close_decision_via_supersedes_sets_superseded(self, client):
|
|
old_decision = client.post(
|
|
"/book/records", json={"kind": "decision", "body": "fence will be green"}, headers=office_headers()
|
|
).json()
|
|
new_decision = client.post(
|
|
"/book/records", json={"kind": "decision", "body": "fence will be red"}, headers=office_headers()
|
|
).json()
|
|
|
|
closed = client.post(
|
|
f"/book/records/{old_decision['id']}/close",
|
|
json={"superseded_by": new_decision["id"]},
|
|
headers=office_headers(),
|
|
).json()
|
|
assert closed["status"] == "superseded"
|
|
|
|
def test_close_note_has_no_transition(self, client):
|
|
note = client.post("/book/records", json={"body": "just a note"}, headers=office_headers()).json()
|
|
response = client.post(
|
|
f"/book/records/{note['id']}/close", json={}, headers=office_headers()
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "invalid_status"
|
|
|
|
|
|
class TestLinkUnlink:
|
|
def test_link_then_unlink(self, client):
|
|
a = client.post("/book/records", json={"body": "a"}, headers=office_headers()).json()
|
|
b = client.post("/book/records", json={"body": "b"}, headers=office_headers()).json()
|
|
|
|
client.post(
|
|
f"/book/records/{a['id']}/link",
|
|
json={"to_id": b["id"], "relation": "related"},
|
|
headers=office_headers(),
|
|
)
|
|
shown = client.get(f"/book/records/{a['id']}", headers=office_headers()).json()
|
|
assert len(shown["links"]) == 1
|
|
|
|
unlinked = client.post(
|
|
f"/book/records/{a['id']}/unlink",
|
|
json={"to_id": b["id"], "relation": "related"},
|
|
headers=office_headers(),
|
|
)
|
|
assert unlinked.status_code == 200
|
|
shown_after = client.get(f"/book/records/{a['id']}", headers=office_headers()).json()
|
|
assert len(shown_after["links"]) == 0
|
|
|
|
def test_unlink_nonexistent_link_is_404(self, client):
|
|
a = client.post("/book/records", json={"body": "a"}, headers=office_headers()).json()
|
|
b = client.post("/book/records", json={"body": "b"}, headers=office_headers()).json()
|
|
response = client.post(
|
|
f"/book/records/{a['id']}/unlink",
|
|
json={"to_id": b["id"], "relation": "blocks"},
|
|
headers=office_headers(),
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestTagUntag:
|
|
def test_tag_then_untag(self, client):
|
|
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
|
|
|
tagged = client.post(
|
|
f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers()
|
|
).json()
|
|
assert tagged["tags"] == ["chore"]
|
|
|
|
untagged = client.post(
|
|
f"/book/records/{record['id']}/untag", json={"tag": "chore"}, headers=office_headers()
|
|
).json()
|
|
assert untagged["tags"] == []
|
|
|
|
def test_tagging_twice_is_idempotent(self, client):
|
|
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
|
client.post(f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers())
|
|
second = client.post(
|
|
f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers()
|
|
)
|
|
assert second.status_code == 200
|
|
assert second.json()["tags"] == ["chore"]
|
|
|
|
|
|
class TestHistory:
|
|
def test_every_write_appends_an_event_carrying_identity(self, client):
|
|
record = client.post(
|
|
"/book/records", json={"body": "x"}, headers=office_headers(identity="jeroen")
|
|
).json()
|
|
client.patch(
|
|
f"/book/records/{record['id']}",
|
|
json={"priority": 2},
|
|
headers=office_headers(identity="tower-control"),
|
|
)
|
|
|
|
history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json()
|
|
actions = [e["action"] for e in history["events"]]
|
|
identities = {e["identity"] for e in history["events"]}
|
|
|
|
assert "add" in actions
|
|
assert "change" in actions
|
|
assert identities == {"jeroen", "tower-control"}
|
|
|
|
def test_history_events_are_immutable_at_the_api(self, client):
|
|
"""There is no update/delete verb for events — only `add` and `change`
|
|
write them, and the append-only trigger (tested in
|
|
test_book_migration.py) is what actually enforces it."""
|
|
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
|
history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json()
|
|
assert len(history["events"]) == 1
|
|
assert history["events"][0]["action"] == "add"
|
|
|
|
def test_a_write_s_event_records_the_consumer(self, client):
|
|
"""T-4: X-Office-Client is retired; book_events.client (no migration
|
|
this round) now carries the consumer instead."""
|
|
record = client.post(
|
|
"/book/records",
|
|
json={"body": "x"},
|
|
headers=office_headers(consumer="office-gemini"),
|
|
).json()
|
|
|
|
history = client.get(
|
|
f"/book/records/{record['id']}/history", headers=office_headers()
|
|
).json()
|
|
assert history["events"][0]["action"] == "add"
|
|
assert history["events"][0]["client"] == "office-gemini"
|
|
|
|
|
|
class TestNumbering:
|
|
"""core-api T-7: `number` is allocated per tenant, never globally."""
|
|
|
|
def test_numbering_starts_at_one_per_tenant_and_increments(self, client):
|
|
first = client.post(
|
|
"/book/records", json={"body": "a"}, headers=office_headers(tenant=VALID_TENANT)
|
|
).json()
|
|
second = client.post(
|
|
"/book/records", json={"body": "b"}, headers=office_headers(tenant=VALID_TENANT)
|
|
).json()
|
|
other_tenant_first = client.post(
|
|
"/book/records", json={"body": "c"}, headers=office_headers(tenant=OTHER_TENANT)
|
|
).json()
|
|
|
|
assert first["number"] == 1
|
|
assert second["number"] == 2
|
|
# A different tenant starts its own sequence at 1 — it can never
|
|
# infer VALID_TENANT's activity from its own numbers.
|
|
assert other_tenant_first["number"] == 1
|
|
|
|
def test_list_number_finds_the_record(self, client):
|
|
record = client.post(
|
|
"/book/records", json={"body": "find me"}, headers=office_headers()
|
|
).json()
|
|
client.post("/book/records", json={"body": "noise"}, headers=office_headers())
|
|
|
|
found = client.get(
|
|
"/book/records", params={"number": record["number"]}, headers=office_headers()
|
|
).json()
|
|
assert found["total"] == 1
|
|
assert found["records"][0]["id"] == record["id"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_two_concurrent_creates_in_one_tenant_get_distinct_numbers(
|
|
self, clean_book_tables, test_database_url
|
|
):
|
|
"""Direct service calls on two independent sessions, run
|
|
concurrently via asyncio.gather, rather than through TestClient
|
|
(whose calls are issued one at a time and so would never actually
|
|
overlap at the database). Exercises BookService._allocate_number's
|
|
FOR UPDATE lock for real."""
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from src.domains.auth.callers import Caller
|
|
from src.domains.book.schemas import BookRecordCreate
|
|
from src.domains.book.service import get_book_service
|
|
|
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
service = get_book_service()
|
|
caller = Caller(kind="office", identity="jeroen", consumer="office-claude", tenant=VALID_TENANT)
|
|
|
|
async def _create(body: str) -> int:
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
try:
|
|
async with session_factory() as session:
|
|
record, _ = await service.add_record(session, BookRecordCreate(body=body), caller)
|
|
return record.number
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
numbers = await asyncio.gather(_create("one"), _create("two"))
|
|
assert sorted(numbers) == [1, 2]
|
|
|
|
|
|
class TestTenantIsolation:
|
|
"""core-api T-7 / workspace D-73: a record in another tenant answers
|
|
exactly like one that does not exist, checked in both directions."""
|
|
|
|
def test_llm_tester_cannot_list_jpmschweitzer_records(self, client):
|
|
client.post(
|
|
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
|
)
|
|
as_other = client.get("/book/records", headers=office_headers(tenant=OTHER_TENANT)).json()
|
|
assert as_other["total"] == 0
|
|
|
|
def test_llm_tester_cannot_show_a_jpmschweitzer_record(self, client):
|
|
record = client.post(
|
|
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
|
).json()
|
|
response = client.get(
|
|
f"/book/records/{record['id']}", headers=office_headers(tenant=OTHER_TENANT)
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"]["code"] == "not_found"
|
|
|
|
def test_jpmschweitzer_cannot_show_an_llm_tester_record(self, client):
|
|
"""The reverse direction of the previous test — the boundary is
|
|
symmetric, not a one-way allowlist for the production tenant."""
|
|
record = client.post(
|
|
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=OTHER_TENANT)
|
|
).json()
|
|
response = client.get(
|
|
f"/book/records/{record['id']}", headers=office_headers(tenant=VALID_TENANT)
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"]["code"] == "not_found"
|
|
|
|
def test_llm_tester_cannot_change_a_jpmschweitzer_record(self, client):
|
|
record = client.post(
|
|
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
|
).json()
|
|
response = client.patch(
|
|
f"/book/records/{record['id']}",
|
|
json={"priority": 1},
|
|
headers=office_headers(tenant=OTHER_TENANT),
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"]["code"] == "not_found"
|
|
|
|
def test_llm_tester_cannot_close_a_jpmschweitzer_record(self, client):
|
|
task = client.post(
|
|
"/book/records",
|
|
json={"kind": "task", "body": "secret"},
|
|
headers=office_headers(tenant=VALID_TENANT),
|
|
).json()
|
|
response = client.post(
|
|
f"/book/records/{task['id']}/close",
|
|
json={"outcome": "done"},
|
|
headers=office_headers(tenant=OTHER_TENANT),
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"]["code"] == "not_found"
|
|
|
|
def test_llm_tester_cannot_tag_a_jpmschweitzer_record(self, client):
|
|
record = client.post(
|
|
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
|
).json()
|
|
response = client.post(
|
|
f"/book/records/{record['id']}/tag",
|
|
json={"tag": "chore"},
|
|
headers=office_headers(tenant=OTHER_TENANT),
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"]["code"] == "not_found"
|
|
|
|
def test_llm_tester_cannot_link_to_a_jpmschweitzer_record(self, client):
|
|
"""`link` refuses a target in another tenant the same way (dossier)."""
|
|
a = client.post(
|
|
"/book/records", json={"body": "a"}, headers=office_headers(tenant=OTHER_TENANT)
|
|
).json()
|
|
b = client.post(
|
|
"/book/records", json={"body": "b"}, headers=office_headers(tenant=VALID_TENANT)
|
|
).json()
|
|
response = client.post(
|
|
f"/book/records/{a['id']}/link",
|
|
json={"to_id": b["id"], "relation": "related"},
|
|
headers=office_headers(tenant=OTHER_TENANT),
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"]["code"] == "not_found"
|
|
|
|
def test_llm_tester_cannot_read_history_of_a_jpmschweitzer_record(self, client):
|
|
record = client.post(
|
|
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
|
).json()
|
|
response = client.get(
|
|
f"/book/records/{record['id']}/history", headers=office_headers(tenant=OTHER_TENANT)
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"]["code"] == "not_found"
|
|
|
|
def test_add_with_a_parent_in_another_tenant_is_404_and_nothing_persists(self, client):
|
|
"""The `parent` convenience link in `add` is a link target too, so
|
|
it is refused the same way `link` is. The attempted child must not
|
|
persist either — the whole add is one transaction."""
|
|
parent = client.post(
|
|
"/book/records", json={"body": "parent"}, headers=office_headers(tenant=OTHER_TENANT)
|
|
).json()
|
|
response = client.post(
|
|
"/book/records",
|
|
json={"body": "child", "parent": parent["id"]},
|
|
headers=office_headers(tenant=VALID_TENANT),
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"]["code"] == "not_found"
|
|
|
|
listing = client.get("/book/records", headers=office_headers(tenant=VALID_TENANT)).json()
|
|
assert listing["total"] == 0
|
|
|
|
def test_find_does_not_leak_a_jpmschweitzer_record_to_llm_tester(self, client):
|
|
client.post(
|
|
"/book/records",
|
|
json={"body": "unique-secret-garden-term"},
|
|
headers=office_headers(tenant=VALID_TENANT),
|
|
)
|
|
found = client.get(
|
|
"/book/records/find",
|
|
params={"q": "unique-secret-garden-term"},
|
|
headers=office_headers(tenant=OTHER_TENANT),
|
|
).json()
|
|
assert found["total"] == 0
|
|
|
|
|
|
class TestUserCallerTenantRequired:
|
|
"""A user-kind caller without a `tatlock_user` claim gets 400
|
|
tenant_required — the Office caller gets this for free from
|
|
callers.py, but a "user"-kind caller's tenant is optional there, so
|
|
Book's own `_require_tenant` is what actually enforces it (dossier
|
|
verification #2)."""
|
|
|
|
def test_user_without_tenant_claim_is_400_on_read(self, client):
|
|
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
|
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
|
|
response = client.get("/book/records", headers={"Authorization": "Bearer some-jwt"})
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "tenant_required"
|
|
|
|
def test_user_without_tenant_claim_is_400_on_write(self, client):
|
|
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
|
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
|
|
response = client.post(
|
|
"/book/records", json={"body": "hi"}, headers={"Authorization": "Bearer some-jwt"}
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "tenant_required"
|
|
|
|
def test_user_with_tenant_claim_succeeds(self, client):
|
|
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": VALID_TENANT,
|
|
}
|
|
response = client.post(
|
|
"/book/records", json={"body": "hi"}, headers={"Authorization": "Bearer some-jwt"}
|
|
)
|
|
assert response.status_code == 201
|
|
assert response.json()["created_by"] == "jeroen"
|
|
assert response.json()["number"] == 1
|
|
|
|
|
|
class TestTenantRegistryEnforcement:
|
|
"""core-api T-8 / workspace D-73: Book's own `_require_tenant` now
|
|
delegates to the tenant registry's one enforcement point, so a Book
|
|
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(
|
|
"/book/records", headers=office_headers(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(
|
|
"/book/records", headers=office_headers(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(
|
|
"/book/records", headers=office_headers(consumer="office-ungranted")
|
|
)
|
|
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("/book/records", headers=office_headers())
|
|
assert response.status_code == 200
|