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:
+237
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user