feat(scribe): accept a feedback message to tower-control with no sender (T-15)
workspace D-70 ("`feedback` needs no address"): the failure channel must
not depend on the thing most likely to break. `scribe_messages.from_identity`
becomes nullable (migration 010); `POST /scribe/messages` accepts a missing
`X-Office-Identity` for exactly one shape — an office caller, `to` is
`["tower-control"]` alone, and `tags` contains `feedback` — stored with
`from_identity` null and `from_consumer` the caller's consumer. Every other
send without an identity is still 400 `identity_required`, enforced in
ScribeService.send_message rather than the caller dependency, since the
decision needs the request body. `MessageResponse.from_identity` is now
optional so mailbox/history/find/message_id answer such a message with a
null sender. Migration 010's downgrade refuses outright while any
sender-less row exists rather than inventing one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ overridden to the test database for every test, building a fresh
|
||||
engine/session per call because TestClient drives the ASGI app on a
|
||||
different event loop than pytest-asyncio's fixture setup.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -68,6 +70,17 @@ def office_headers(identity: str, consumer: str, tenant: str = TENANT) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def office_headers_no_identity(consumer: str, tenant: str = TENANT) -> dict:
|
||||
"""No `X-Office-Identity` at all — core-api T-15's one exemption (an
|
||||
office caller's `feedback` to `tower-control` alone) is reachable only
|
||||
through this shape; every other write still needs the header."""
|
||||
return {
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Consumer": consumer,
|
||||
"X-Office-Tenant": tenant,
|
||||
}
|
||||
|
||||
|
||||
def register(client, identity: str, consumer: str, tenant: str = TENANT):
|
||||
return client.post(
|
||||
"/scribe/addresses",
|
||||
@@ -403,6 +416,121 @@ class TestSendValidation:
|
||||
assert reply.json()["detail"]["code"] == "not_found"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# `feedback` needs no address (core-api T-15 / workspace D-70): the one
|
||||
# shape that may ship with no sender is an office caller's `feedback` to
|
||||
# `tower-control` alone — nothing broader.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFeedbackWithoutIdentity:
|
||||
def test_feedback_to_tower_control_alone_with_no_identity_succeeds(self, client):
|
||||
register(client, "tower-control", "office-gemini")
|
||||
|
||||
sent = client.post(
|
||||
"/scribe/messages",
|
||||
json={
|
||||
"to": ["tower-control"],
|
||||
"body": "the find verb has no limit guard",
|
||||
"tags": ["feedback"],
|
||||
},
|
||||
headers=office_headers_no_identity("office-claude"),
|
||||
)
|
||||
assert sent.status_code == 201
|
||||
message = sent.json()
|
||||
assert message["from_identity"] is None
|
||||
assert message["from_consumer"] == "office-claude"
|
||||
assert message["topics"] == ["tower-control"]
|
||||
assert message["tags"] == ["feedback"]
|
||||
|
||||
mailbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
assert mailbox["total"] == 1
|
||||
assert mailbox["entries"][0]["message"]["from_identity"] is None
|
||||
assert mailbox["entries"][0]["message"]["id"] == message["id"]
|
||||
|
||||
history = client.get(
|
||||
"/scribe/addresses/tower-control/history",
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert history["messages"][0]["from_identity"] is None
|
||||
|
||||
found = client.get(
|
||||
"/scribe/messages",
|
||||
params={"q": "limit"},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert found["total"] == 1
|
||||
assert found["entries"][0]["message"]["from_identity"] is None
|
||||
|
||||
def test_feedback_with_no_identity_still_needs_a_known_tenant(self, client):
|
||||
"""No sender is not no tenant (the brief): `_require_tenant` keeps
|
||||
running on this path, ahead of the identity decision."""
|
||||
response = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
|
||||
headers=office_headers_no_identity("office-claude", tenant="no-such-tenant"),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "tenant_unknown"
|
||||
|
||||
def test_feedback_with_no_identity_still_needs_a_granted_consumer(self, client):
|
||||
response = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
|
||||
headers=office_headers_no_identity("office-ungranted"),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "consumer_not_granted"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"to,tags",
|
||||
[
|
||||
(["tower-control", "jeroen"], ["feedback"]), # a second recipient
|
||||
(["jeroen"], ["feedback"]), # another recipient, not tower-control
|
||||
(["tower-control"], []), # no feedback tag
|
||||
(["tower-control"], ["urgent"]), # tagged, but not feedback
|
||||
],
|
||||
ids=[
|
||||
"second-recipient",
|
||||
"another-recipient",
|
||||
"no-feedback-tag",
|
||||
"tagged-but-not-feedback",
|
||||
],
|
||||
)
|
||||
def test_every_broader_shape_with_no_identity_is_still_400(self, client, to, tags):
|
||||
response = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": to, "body": "x", "tags": tags},
|
||||
headers=office_headers_no_identity("office-claude"),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_required"
|
||||
|
||||
def test_user_kind_caller_gains_nothing_from_the_exemption(self, client):
|
||||
"""A user-kind caller's identity is never empty (callers.py always
|
||||
falls back to a name, never ""), so it never reaches the
|
||||
no-identity branch at all: sending this exact shape is unaffected
|
||||
by T-15 and still needs an owned address, same as before. Not 400
|
||||
identity_required — that code names an office caller resolved with
|
||||
none; this is 403 identity_not_owned, because "jeroen" was never
|
||||
registered under this (consumer-less) user identity."""
|
||||
register(client, "tower-control", "office-gemini")
|
||||
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": TENANT,
|
||||
}
|
||||
response = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
|
||||
headers={"Authorization": "Bearer some-jwt"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "identity_not_owned"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Isolation (dossier verification #3): the hard tenant boundary, both
|
||||
# directions — every cross-tenant answer is indistinguishable from "does
|
||||
|
||||
Reference in New Issue
Block a user