Files
core-api/tests/test_book.py
T
jpmschweitzerandClaude Sonnet 5 e02173aefb feat(book): tenant boundary and per-tenant numbering on the Book (T-7)
005 has never run in production (still at 004), so it is amended in place
rather than patched later by a 007: book_records gains owner_tenant (the
tatlock tenant, Caller.tenant from T-4) and number, a short outward id
allocated per tenant via a new book_tenant_counters row locked FOR UPDATE
in the same transaction as the insert — INSERT ... ON CONFLICT DO NOTHING
bootstraps the counter row so a tenant's very first allocation doesn't
race its own creation. Unique on (owner_tenant, number); composite index
on (owner_tenant, kind, status). 006 applies on top unchanged.

Every Book route now requires a tenant (BookService._require_tenant,
mirroring the Scribe's own pattern) and filters on it: a record in
another tenant answers exactly like one that does not exist, in both
directions. link and add's parent convenience both route through
_get_or_404, so a link can never reach another tenant — which is also
what keeps show's links/backlinks free of their own tenant filter, since
every link touching a tenant's record is created tenant-safe already.
Responses include number; list accepts number= to find one.

Verified: core_api_test downgraded to 004 (with the original migration,
to match its already-applied pre-amendment schema), amended, upgraded
back to head — clean round trip. 508 tests pass (489 baseline + 19 new:
3 migration-shape, 16 tenant/numbering). Mutation checks confirmed and
reverted: dropping list's tenant filter, allocating numbers from a global
counter, and dropping link's tenant check on its target each fail the
test built to catch it. Raw REST against a dev server on :8777 against
core_api_test confirms the same behaviour over the wire: two tenants'
first records both land on number 1, and a cross-tenant read answers 404
not_found.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 16:28:08 +02:00

724 lines
29 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