feat(tenants): add a default address on each consumer grant (T-11)
claude.ai's connector keeps no MCP session between tool calls, so the
Office cannot remember which address a conversation chose. Each consumer
grant now carries a fallback: the Office uses it when a call names none
and has no session binding (tatlock-office T-5, in a parallel round,
reads it).
Migration 008 (down_revision="007") adds
tenant_consumers.default_address — nullable varchar(63) — and a
composite foreign key (tenant_id, default_address) ->
scribe_addresses(owner_tenant, name), so a default always names a real
address in the same tenant. scribe_addresses already carries both a
primary key and a named unique constraint on (owner_tenant, name)
(migration 006), so the composite FK has something to bind to; Postgres's
default MATCH SIMPLE means a NULL default_address never triggers the
check, which is the state every grant but one is left in. The data step
sets jpmschweitzer/office-claude -> claude-ai only if that address exists
at upgrade time (true in production, false in core_api_test, where no
scribe_addresses are seeded) — an UPDATE ... WHERE EXISTS, not a bare
UPDATE, so it is a no-op rather than a failure when absent.
llm_tester/office-claude is untouched and stays null. Downgrade drops the
FK then the column.
TenantConsumer (models.py) gains the column and a matching
ForeignKeyConstraint. TenantsService.get_me now returns a third element,
the caller's own consumer's default_address (looked up only when
caller.consumer is not None — a user-kind caller carries no consumer, so
there is no grant row to hold a default for it) or None. GET /tenants/me
answers it; GET /tenants and its TenantSummary schema are unchanged.
Tests: TestMigration008RoundTrip/ForeignKey/DataStep added to
tests/test_tenants_migration.py (round-trip 007<->008, the FK existing
and refusing an unknown address while allowing null, the data step's
no-op and positive cases). tests/conftest.py gains a
default_address_grant fixture (registers a throwaway address, points a
grant at it, reverses both on teardown). tests/test_tenants.py gains
TestMeDefaultAddress (an office caller with/without a default, a user
caller always null).
Friction found and fixed in the same commit: the new composite FK made
Postgres refuse tests/test_scribe.py's existing clean_scribe_tables
TRUNCATE ("cannot truncate a table referenced in a foreign key
constraint") — the restriction is on the constraint's existence, not on
whether any row's default_address is actually non-null. tenant_consumers
joins that TRUNCATE; CASCADE was rejected because it would silently drop
every grant rather than only addresses, so the four grants
(jpmschweitzer/llm_tester x office-claude/office-gemini) are reinserted
in the same fixture, right after.
Mutation-checked: forcing default_address to null in the controller
fails exactly test_office_caller_with_a_default_sees_it, leaving the
without-a-default and user-caller tests green. Disabling the FK's
creation (and, symmetrically, its drop in downgrade — to keep the
round-trip self-consistent against the live core_api_test schema) fails
test_fk_exists and test_fk_refuses_a_default_naming_an_address_that_does_not_exist,
leaving test_fk_allows_a_null_default_regardless_of_tenant green. Both
mutations reverted; the full suite re-verified green (543 + 11 = 554
passed).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
"""Add a default address to each consumer grant (core-api T-11)
|
||||||
|
|
||||||
|
Revision ID: 008
|
||||||
|
Revises: 007
|
||||||
|
Create Date: 2026-10-19
|
||||||
|
|
||||||
|
claude.ai's connector keeps no MCP session between tool calls, so the
|
||||||
|
Office cannot remember which address a conversation chose. Each consumer
|
||||||
|
grant now carries `default_address`: the Office uses it when a call names
|
||||||
|
none and has no session binding (tatlock-office T-5 reads it).
|
||||||
|
|
||||||
|
`tenant_consumers.default_address` is nullable, and a composite foreign
|
||||||
|
key `(tenant_id, default_address)` -> `scribe_addresses(owner_tenant,
|
||||||
|
name)` ensures a default always names a real address in the same tenant.
|
||||||
|
Postgres's default `MATCH SIMPLE` means a NULL `default_address` never
|
||||||
|
triggers the check, so an ungranted default is simply absent rather than
|
||||||
|
a constraint violation waiting to happen.
|
||||||
|
|
||||||
|
Data step: `jpmschweitzer`/`office-claude` defaults to `claude-ai` only if
|
||||||
|
that address already exists at upgrade time - true in production, false
|
||||||
|
in `core_api_test` (no scribe_addresses are seeded there), so the `UPDATE
|
||||||
|
... WHERE EXISTS` must be - and is - a no-op rather than a failure when
|
||||||
|
the address is absent. `llm_tester`/`office-claude` is not touched and
|
||||||
|
stays null.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "008"
|
||||||
|
down_revision: Union[str, None] = "007"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add the column, bind it to scribe_addresses, then seed the one
|
||||||
|
production default the dossier specifies."""
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"tenant_consumers",
|
||||||
|
sa.Column("default_address", sa.String(length=63), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_tenant_consumers_default_address",
|
||||||
|
"tenant_consumers",
|
||||||
|
"scribe_addresses",
|
||||||
|
["tenant_id", "default_address"],
|
||||||
|
["owner_tenant", "name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only if claude-ai already exists in jpmschweitzer (production: yes;
|
||||||
|
# core_api_test: no) - an UPDATE ... WHERE EXISTS is a no-op rather
|
||||||
|
# than a failure when it does not.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tenant_consumers
|
||||||
|
SET default_address = 'claude-ai'
|
||||||
|
WHERE tenant_id = 'jpmschweitzer'
|
||||||
|
AND consumer = 'office-claude'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM scribe_addresses
|
||||||
|
WHERE owner_tenant = 'jpmschweitzer' AND name = 'claude-ai'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Unbind and drop the column."""
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_tenant_consumers_default_address", "tenant_consumers", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_column("tenant_consumers", "default_address")
|
||||||
@@ -51,7 +51,7 @@ class TenantsController(BaseController):
|
|||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
tenant, consumers = await service.get_me(session, caller)
|
tenant, consumers, default_address = await service.get_me(session, caller)
|
||||||
except TenantError as error:
|
except TenantError as error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=error.status_code, detail={"code": error.code}
|
status_code=error.status_code, detail={"code": error.code}
|
||||||
@@ -62,6 +62,7 @@ class TenantsController(BaseController):
|
|||||||
kind=tenant.kind,
|
kind=tenant.kind,
|
||||||
status=tenant.status,
|
status=tenant.status,
|
||||||
consumers=consumers,
|
consumers=consumers,
|
||||||
|
default_address=default_address,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|||||||
@@ -7,10 +7,25 @@ and `TenantsService.require_tenant` is the one place a caller's tenant is
|
|||||||
checked known, active, and — for an Office caller — granted to the calling
|
checked known, active, and — for an Office caller — granted to the calling
|
||||||
consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
||||||
src/domains/book/models.py.
|
src/domains/book/models.py.
|
||||||
|
|
||||||
|
`TenantConsumer.default_address` (migration 008, core-api T-11) is each
|
||||||
|
grant's fallback address for a caller that names none and holds no session
|
||||||
|
binding — claude.ai's connector keeps no MCP session between tool calls, so
|
||||||
|
the Office needs this to answer without one. The composite FK keeps it
|
||||||
|
honest: a default always names a real address in the same tenant.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Text, func
|
from sqlalchemy import (
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
ForeignKeyConstraint,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from src.shared.database import Base
|
from src.shared.database import Base
|
||||||
@@ -55,14 +70,26 @@ class Tenant(Base):
|
|||||||
|
|
||||||
|
|
||||||
class TenantConsumer(Base):
|
class TenantConsumer(Base):
|
||||||
"""A grant: consumer `consumer` may act in tenant `tenant_id`."""
|
"""A grant: consumer `consumer` may act in tenant `tenant_id`, with an
|
||||||
|
optional default address (migration 008, core-api T-11) — the composite
|
||||||
|
FK below means a non-null default always names a real
|
||||||
|
`scribe_addresses` row in this same tenant."""
|
||||||
|
|
||||||
__tablename__ = "tenant_consumers"
|
__tablename__ = "tenant_consumers"
|
||||||
|
__table_args__ = (
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["tenant_id", "default_address"],
|
||||||
|
["scribe_addresses.owner_tenant", "scribe_addresses.name"],
|
||||||
|
name="fk_tenant_consumers_default_address",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
tenant_id: Mapped[str] = mapped_column(
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
String(63), ForeignKey("tenants.id"), primary_key=True
|
String(63), ForeignKey("tenants.id"), primary_key=True
|
||||||
)
|
)
|
||||||
consumer: Mapped[str] = mapped_column(String(63), primary_key=True)
|
consumer: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||||
|
# FK to scribe_addresses (owner_tenant, name) added by migration 008.
|
||||||
|
default_address: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Pydantic request/response payloads for the tenant registry (core-api T-8 /
|
|||||||
workspace D-73). No write routes this round — provisioning is later
|
workspace D-73). No write routes this round — provisioning is later
|
||||||
(workspace T-195).
|
(workspace T-195).
|
||||||
"""
|
"""
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from src.shared.base import BaseSchema
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
@@ -21,9 +21,14 @@ class TenantSummary(BaseSchema):
|
|||||||
|
|
||||||
class TenantMeResponse(TenantSummary):
|
class TenantMeResponse(TenantSummary):
|
||||||
"""`GET /tenants/me` — the caller's own tenant plus the consumers
|
"""`GET /tenants/me` — the caller's own tenant plus the consumers
|
||||||
granted in it, the cheap "am I allowed here" probe (D-73)."""
|
granted in it, the cheap "am I allowed here" probe (D-73).
|
||||||
|
|
||||||
|
`default_address` (core-api T-11) is the caller's own consumer's
|
||||||
|
default address, or null — null for a user-kind caller (no consumer to
|
||||||
|
look a default up for) and for an office caller whose grant has none."""
|
||||||
|
|
||||||
consumers: List[str]
|
consumers: List[str]
|
||||||
|
default_address: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TenantListResponse(BaseSchema):
|
class TenantListResponse(BaseSchema):
|
||||||
|
|||||||
@@ -81,9 +81,16 @@ class TenantsService:
|
|||||||
|
|
||||||
return tenant
|
return tenant
|
||||||
|
|
||||||
async def get_me(self, session: AsyncSession, caller: Caller) -> Tuple[Tenant, List[str]]:
|
async def get_me(
|
||||||
|
self, session: AsyncSession, caller: Caller
|
||||||
|
) -> Tuple[Tenant, List[str], Optional[str]]:
|
||||||
"""`GET /tenants/me` — through the same helper, so "am I allowed
|
"""`GET /tenants/me` — through the same helper, so "am I allowed
|
||||||
here" shares its answer with every other tenant-scoped call."""
|
here" shares its answer with every other tenant-scoped call.
|
||||||
|
|
||||||
|
The third element is the caller's own consumer's default address
|
||||||
|
(core-api T-11), or None — looked up only for an office caller
|
||||||
|
(`caller.consumer is not None`); a user-kind caller carries no
|
||||||
|
consumer, so there is no grant row to hold a default for it."""
|
||||||
tenant_id = await self.require_tenant(session, caller)
|
tenant_id = await self.require_tenant(session, caller)
|
||||||
|
|
||||||
tenant = await session.get(Tenant, tenant_id)
|
tenant = await session.get(Tenant, tenant_id)
|
||||||
@@ -93,7 +100,16 @@ class TenantsService:
|
|||||||
.order_by(TenantConsumer.consumer)
|
.order_by(TenantConsumer.consumer)
|
||||||
)
|
)
|
||||||
consumers = list((await session.execute(consumers_stmt)).scalars().all())
|
consumers = list((await session.execute(consumers_stmt)).scalars().all())
|
||||||
return tenant, consumers
|
|
||||||
|
default_address: Optional[str] = None
|
||||||
|
if caller.consumer is not None:
|
||||||
|
default_address = await session.scalar(
|
||||||
|
select(TenantConsumer.default_address).where(
|
||||||
|
TenantConsumer.tenant_id == tenant_id,
|
||||||
|
TenantConsumer.consumer == caller.consumer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tenant, consumers, default_address
|
||||||
|
|
||||||
async def list_tenants(self, session: AsyncSession, caller: Caller) -> List[Tenant]:
|
async def list_tenants(self, session: AsyncSession, caller: Caller) -> List[Tenant]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+86
-4
@@ -225,13 +225,36 @@ async def scribe_session(scribe_schema: str):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def clean_scribe_tables(scribe_session):
|
async def clean_scribe_tables(scribe_session):
|
||||||
"""Scribe service methods commit internally (same reason as Book's
|
"""Scribe service methods commit internally (same reason as Book's
|
||||||
clean_book_tables). TRUNCATE all three tables together so Postgres
|
clean_book_tables). TRUNCATE all tables together so Postgres resolves
|
||||||
resolves the FK order (mailbox_entries -> messages, messages'
|
the FK order (mailbox_entries -> messages, messages' self-referencing
|
||||||
self-referencing reply_to) across the one statement."""
|
reply_to) across the one statement.
|
||||||
|
|
||||||
|
`tenant_consumers` joined the list for migration 008 (core-api T-11):
|
||||||
|
its new composite FK `(tenant_id, default_address)` ->
|
||||||
|
`scribe_addresses(owner_tenant, name)` means Postgres now refuses to
|
||||||
|
truncate `scribe_addresses` alone — "cannot truncate a table
|
||||||
|
referenced in a foreign key constraint" — a restriction on the
|
||||||
|
constraint's existence, not on whether any row's `default_address` is
|
||||||
|
actually non-null (it never is here; migration 008's data step is a
|
||||||
|
no-op in `core_api_test`). `TRUNCATE ... CASCADE` was rejected: it
|
||||||
|
would silently drop every grant rather than only addresses, so the
|
||||||
|
four grants this file's tests depend on — both seeded tenants x
|
||||||
|
office-claude/office-gemini, the same pairing `_grant_test_office_gemini`
|
||||||
|
establishes once per session — are reinserted immediately after."""
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
await scribe_session.execute(
|
await scribe_session.execute(
|
||||||
text("TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses")
|
text(
|
||||||
|
"TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses, "
|
||||||
|
"tenant_consumers"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await scribe_session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO tenant_consumers (tenant_id, consumer) VALUES "
|
||||||
|
"('jpmschweitzer', 'office-claude'), ('llm_tester', 'office-claude'), "
|
||||||
|
"('jpmschweitzer', 'office-gemini'), ('llm_tester', 'office-gemini')"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await scribe_session.commit()
|
await scribe_session.commit()
|
||||||
yield scribe_session
|
yield scribe_session
|
||||||
@@ -287,3 +310,62 @@ async def suspended_tenant(test_database_url: str):
|
|||||||
await session.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id})
|
await session.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id})
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def default_address_grant(tenant_registry: str, test_database_url: str):
|
||||||
|
"""
|
||||||
|
Give `jpmschweitzer`/`office-claude` a default address (core-api T-11):
|
||||||
|
registers a throwaway `scribe_addresses` row first — migration 008's
|
||||||
|
composite FK means the grant's default must name a real address in the
|
||||||
|
same tenant — then points the grant at it. Reversed on teardown (default
|
||||||
|
back to null, the address row deleted) so the rest of the suite sees the
|
||||||
|
null default that `core_api_test`'s own copy of migration 008 actually
|
||||||
|
leaves this grant with (see test_tenants_migration.py's
|
||||||
|
TestMigration008DataStep: claude-ai is never seeded here).
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
tenant_id = "jpmschweitzer"
|
||||||
|
consumer = "office-claude"
|
||||||
|
address = "test-default-address"
|
||||||
|
|
||||||
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||||
|
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO scribe_addresses (owner_tenant, name, created_by) "
|
||||||
|
"VALUES (:tenant, :address, 'test-harness')"
|
||||||
|
),
|
||||||
|
{"tenant": tenant_id, "address": address},
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tenant_consumers SET default_address = :address "
|
||||||
|
"WHERE tenant_id = :tenant AND consumer = :consumer"
|
||||||
|
),
|
||||||
|
{"tenant": tenant_id, "address": address, "consumer": consumer},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
yield address
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tenant_consumers SET default_address = NULL "
|
||||||
|
"WHERE tenant_id = :tenant AND consumer = :consumer"
|
||||||
|
),
|
||||||
|
{"tenant": tenant_id, "consumer": consumer},
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
text("DELETE FROM scribe_addresses WHERE owner_tenant = :tenant AND name = :address"),
|
||||||
|
{"tenant": tenant_id, "address": address},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
|||||||
@@ -164,6 +164,35 @@ class TestMe:
|
|||||||
assert response.json()["id"] == TENANT
|
assert response.json()["id"] == TENANT
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeDefaultAddress:
|
||||||
|
"""`GET /tenants/me`'s `default_address` (core-api T-11): the Office's
|
||||||
|
fallback for a caller that names no address and holds no session
|
||||||
|
binding — null unless the grant itself has one."""
|
||||||
|
|
||||||
|
def test_office_caller_with_a_default_sees_it(self, client, default_address_grant):
|
||||||
|
response = client.get("/tenants/me", headers=office_headers())
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["default_address"] == default_address_grant
|
||||||
|
|
||||||
|
def test_office_caller_without_a_default_sees_null(self, client):
|
||||||
|
response = client.get("/tenants/me", headers=office_headers())
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["default_address"] is None
|
||||||
|
|
||||||
|
def test_user_caller_sees_null(self, client):
|
||||||
|
"""A user-kind caller carries no consumer, so there is no grant row
|
||||||
|
to look a default up for — null regardless of any office grant's
|
||||||
|
own default."""
|
||||||
|
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.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["default_address"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestList:
|
class TestList:
|
||||||
"""`GET /tenants` — the hard boundary: another tenant's existence is
|
"""`GET /tenants` — the hard boundary: another tenant's existence is
|
||||||
never revealed to a tenant-bearing caller."""
|
never revealed to a tenant-bearing caller."""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Tests for the tenant registry migration 007 (core-api T-8, workspace D-73).
|
Tests for the tenant registry migrations 007 (core-api T-8, workspace D-73)
|
||||||
|
and 008 (core-api T-11, the default-address column).
|
||||||
|
|
||||||
Runs against `core_api_test` only (see conftest.py — skip when
|
Runs against `core_api_test` only (see conftest.py — skip when
|
||||||
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
||||||
@@ -186,3 +187,196 @@ class TestMigration007ForeignKeys:
|
|||||||
await conn.commit()
|
await conn.commit()
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration008RoundTrip:
|
||||||
|
"""Migration 008 (core-api T-11) must upgrade and downgrade cleanly
|
||||||
|
(dossier verification #2) — same pattern as TestMigration007RoundTrip,
|
||||||
|
one revision up."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upgrade_adds_default_address_column(self, test_database_url):
|
||||||
|
result = run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'",
|
||||||
|
)
|
||||||
|
assert found == "default_address"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
|
||||||
|
baseline = run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
assert baseline.returncode == 0, baseline.stderr
|
||||||
|
|
||||||
|
down = run_alembic(test_database_url, "downgrade", "007")
|
||||||
|
assert down.returncode == 0, down.stderr
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'",
|
||||||
|
)
|
||||||
|
assert found is None
|
||||||
|
# tenant_consumers itself, and the rest of the registry, must
|
||||||
|
# survive a downgrade of 008 — proves 008 only touches its own
|
||||||
|
# column and FK.
|
||||||
|
assert await _table_exists(test_database_url, "tenant_consumers")
|
||||||
|
assert await _table_exists(test_database_url, "tenants")
|
||||||
|
|
||||||
|
# Leave the schema at head for the rest of the suite.
|
||||||
|
up = run_alembic(test_database_url, "upgrade", "008")
|
||||||
|
assert up.returncode == 0, up.stderr
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'",
|
||||||
|
)
|
||||||
|
assert found == "default_address"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration008ForeignKey:
|
||||||
|
"""The composite FK `(tenant_id, default_address)` ->
|
||||||
|
`scribe_addresses(owner_tenant, name)` (dossier verification #2/#3): a
|
||||||
|
default always names a real address in the same tenant."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fk_exists(self, test_database_url):
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT conname FROM pg_constraint "
|
||||||
|
"WHERE conname = 'fk_tenant_consumers_default_address' AND contype = 'f'",
|
||||||
|
)
|
||||||
|
assert found == "fk_tenant_consumers_default_address"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fk_refuses_a_default_naming_an_address_that_does_not_exist(
|
||||||
|
self, test_database_url
|
||||||
|
):
|
||||||
|
"""dossier verification #3: "A test inserts a default naming an
|
||||||
|
address that does not exist and sees the FK refuse it.\""""
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tenant_consumers SET default_address = 'no-such-address' "
|
||||||
|
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fk_allows_a_null_default_regardless_of_tenant(self, test_database_url):
|
||||||
|
"""A NULL default_address never triggers the composite check
|
||||||
|
(Postgres's default MATCH SIMPLE) — explicit, since this is the
|
||||||
|
state every grant but jpmschweitzer/office-claude is left in."""
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tenant_consumers SET default_address = NULL "
|
||||||
|
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT default_address FROM tenant_consumers "
|
||||||
|
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'",
|
||||||
|
)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration008DataStep:
|
||||||
|
"""jpmschweitzer/office-claude defaults to `claude-ai` only if that
|
||||||
|
address exists at upgrade time (dossier: "an UPDATE ... WHERE EXISTS
|
||||||
|
does this"); `llm_tester`/office-claude is never touched."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_is_a_noop_when_claude_ai_is_absent(self, test_database_url):
|
||||||
|
"""core_api_test seeds no scribe_addresses rows, so the data step
|
||||||
|
must run without error and leave the default null — this is the
|
||||||
|
state production's own test database is in today."""
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
down = run_alembic(test_database_url, "downgrade", "007")
|
||||||
|
assert down.returncode == 0, down.stderr
|
||||||
|
|
||||||
|
up = run_alembic(test_database_url, "upgrade", "008")
|
||||||
|
assert up.returncode == 0, up.stderr
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT default_address FROM tenant_consumers "
|
||||||
|
"WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'",
|
||||||
|
)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_llm_tester_office_claude_stays_null(self, test_database_url):
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT default_address FROM tenant_consumers "
|
||||||
|
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'",
|
||||||
|
)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sets_claude_ai_when_the_address_exists_at_upgrade_time(
|
||||||
|
self, test_database_url
|
||||||
|
):
|
||||||
|
"""Register claude-ai in jpmschweitzer *before* 008 runs (the
|
||||||
|
production shape), downgrade to 007 so the data step runs again on
|
||||||
|
the way back up, then prove the UPDATE actually sets the default
|
||||||
|
rather than leaving it null regardless — the mutation check's
|
||||||
|
positive case. Cleans up its own address and resets the default so
|
||||||
|
the rest of the suite sees the null state it otherwise expects."""
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
down = run_alembic(test_database_url, "downgrade", "007")
|
||||||
|
assert down.returncode == 0, down.stderr
|
||||||
|
|
||||||
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO scribe_addresses (owner_tenant, name, created_by) "
|
||||||
|
"VALUES ('jpmschweitzer', 'claude-ai', 'test-harness')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
up = run_alembic(test_database_url, "upgrade", "008")
|
||||||
|
assert up.returncode == 0, up.stderr
|
||||||
|
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT default_address FROM tenant_consumers "
|
||||||
|
"WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'",
|
||||||
|
)
|
||||||
|
assert found == "claude-ai"
|
||||||
|
finally:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tenant_consumers SET default_address = NULL "
|
||||||
|
"WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"DELETE FROM scribe_addresses "
|
||||||
|
"WHERE owner_tenant = 'jpmschweitzer' AND name = 'claude-ai'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await engine.dispose()
|
||||||
|
|||||||
Reference in New Issue
Block a user