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>
This commit is contained in:
2026-10-19 16:28:08 +02:00
co-authored by Claude Sonnet 5
parent e52c727d99
commit e02173aefb
10 changed files with 533 additions and 44 deletions
+237 -1
View File
@@ -1,12 +1,14 @@
"""
Tests for the Book domain (T-1 / D-68, D-70) against `core_api_test`.
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
@@ -18,6 +20,8 @@ from src.shared.database import get_async_session
settings = get_settings()
OTHER_TENANT = "llm_tester"
OFFICE_KEY = "test-office-key"
@@ -485,3 +489,235 @@ class TestHistory:
).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