The Office (workspace D-73) now signs every visitor in through Authentik with one OAuth client per consumer, and every token carries a signed tatlock_user claim. Caller gains consumer (X-Office-Consumer, the Authentik application slug) and tenant (X-Office-Tenant, the tatlock_user claim). For kind "office" both are required on every call, reads included — missing is 400 consumer_required/tenant_required, malformed is 400 consumer_invalid/tenant_invalid. Every other kind ignores both headers outright: tatlock and scheduler carry no tenant, and a "user" caller's tenant comes only from their own JWT's tatlock_user claim, never from a header — a header must not be able to put a non-Office caller into a tenant. X-Office-Client (informational) is retired in favour of X-Office-Consumer. book_events.client keeps its name (no migration this round) and now records the consumer instead. Book routes do not start filtering by tenant this round — that is core-api T-2. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
488 lines
19 KiB
Python
488 lines
19 KiB
Python
"""
|
|
Tests for the Book domain (T-1 / D-68, D-70) 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 uuid
|
|
|
|
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()
|
|
|
|
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"
|