Implement PostgreSQL production store path
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Add the PostgreSQL backend, migration and stopped-write transfer tools, lease-aware deployment manifests, tenancy declarations, and shared conformance coverage. Persist grouping mutations in durable stores and separate process liveness from database readiness.
This commit is contained in:
parent
2063470ac8
commit
749461b97b
30 changed files with 2364 additions and 71 deletions
30
tests/postgres_backend.py
Normal file
30
tests/postgres_backend.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tenant_engine.postgres_store import PostgresTenantStore
|
||||
|
||||
|
||||
def clean_postgres_store(tmp_path: Path) -> PostgresTenantStore:
|
||||
dsn = os.getenv("TENANT_ENGINE_TEST_DATABASE_URL", "").strip()
|
||||
if not dsn:
|
||||
pytest.skip("set TENANT_ENGINE_TEST_DATABASE_URL to exercise PostgreSQL conformance")
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError:
|
||||
pytest.skip("install tenant-engine[postgres] to exercise PostgreSQL conformance")
|
||||
|
||||
migration = Path(__file__).parents[1] / "migrations/postgres/0001_tenant_store.sql"
|
||||
with psycopg.connect(dsn, autocommit=True) as connection:
|
||||
connection.execute(migration.read_text(encoding="utf-8"))
|
||||
connection.execute(
|
||||
"""TRUNCATE guardrail_changes, guardrail_overrides,
|
||||
idempotency_receipts, events, plans, grants, tenants
|
||||
RESTART IDENTITY CASCADE"""
|
||||
)
|
||||
url_file = tmp_path / "postgres-url"
|
||||
url_file.write_text(dsn, encoding="utf-8")
|
||||
return PostgresTenantStore(str(url_file), min_pool_size=1, max_pool_size=4)
|
||||
|
|
@ -317,7 +317,7 @@ def test_a_cross_tenant_write_changes_nothing():
|
|||
client = make_client(_TenantScopedAuthorizer("t-own"))
|
||||
assert put(client).status_code == 403
|
||||
# the store was never touched: the version is untouched
|
||||
assert client.get("/tenants/t-1").json()["version"] == 1
|
||||
assert client.app.state.store.get_tenant("t-1").version == 1
|
||||
|
||||
|
||||
def test_a_store_outage_fails_closed_on_write():
|
||||
|
|
@ -340,9 +340,9 @@ def test_errors_never_reflect_policy_internals():
|
|||
|
||||
|
||||
def test_existing_endpoints_are_unaffected(client):
|
||||
assert client.get("/tenants/t-1").status_code == 200
|
||||
assert client.get("/tenants/t-1/roles").status_code == 200
|
||||
assert client.get("/tenants/t-1/roles/live").status_code == 200
|
||||
assert client.get("/tenants/t-1", params={"actor": "tenant-engine"}).status_code == 200
|
||||
assert client.get("/tenants/t-1/roles", params={"actor": "tenant-engine"}).status_code == 200
|
||||
assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).status_code == 200
|
||||
assert client.get("/health").status_code == 200
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ def _patch(client, *, headers=None, metadata=None, **overrides):
|
|||
|
||||
|
||||
def test_get_tenant_returns_record_and_etag(client) -> None:
|
||||
response = client.get("/tenants/t-1")
|
||||
response = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["ETag"] == '"1"'
|
||||
|
|
@ -79,13 +79,15 @@ def test_get_tenant_returns_record_and_etag(client) -> None:
|
|||
|
||||
|
||||
def test_get_tenant_resolves_by_identifier(client) -> None:
|
||||
response = client.get("/tenants/tenant:friendly:binky")
|
||||
response = client.get(
|
||||
"/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["tenant_id"] == "t-1"
|
||||
|
||||
|
||||
def test_get_unknown_tenant_is_404(client) -> None:
|
||||
assert client.get("/tenants/nope").status_code == 404
|
||||
assert client.get("/tenants/nope", params={"actor": "tenant-engine"}).status_code == 404
|
||||
|
||||
|
||||
# -- update -------------------------------------------------------------
|
||||
|
|
@ -98,7 +100,8 @@ def test_update_succeeds_and_advances_the_etag(client) -> None:
|
|||
assert response.headers["ETag"] == '"2"'
|
||||
assert response.headers["Idempotent-Replay"] == "false"
|
||||
assert response.json()["display_name"] == "Binky Ltd"
|
||||
assert client.get("/tenants/t-1").json()["display_name"] == "Binky Ltd"
|
||||
persisted = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
|
||||
assert persisted.json()["display_name"] == "Binky Ltd"
|
||||
|
||||
|
||||
def test_update_rejects_unknown_field(client) -> None:
|
||||
|
|
@ -110,7 +113,8 @@ def test_update_rejects_identifier_mutation(client) -> None:
|
|||
response = _patch(client, metadata={"identifier": "tenant:large:other"})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert client.get("/tenants/t-1").json()["identifier"] == "tenant:friendly:binky"
|
||||
persisted = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
|
||||
assert persisted.json()["identifier"] == "tenant:friendly:binky"
|
||||
|
||||
|
||||
def test_update_rejects_empty_metadata(client) -> None:
|
||||
|
|
@ -138,7 +142,7 @@ def test_duplicate_idempotency_key_replays(client) -> None:
|
|||
assert replay.status_code == 200
|
||||
assert replay.headers["Idempotent-Replay"] == "true"
|
||||
assert replay.json() == first.json()
|
||||
assert client.get("/tenants/t-1").json()["version"] == 2
|
||||
assert client.get("/tenants/t-1", params={"actor": "tenant-engine"}).json()["version"] == 2
|
||||
|
||||
|
||||
def test_idempotency_key_reused_for_a_different_request_is_409(client) -> None:
|
||||
|
|
@ -290,7 +294,7 @@ def test_update_permission_does_not_imply_retire_permission() -> None:
|
|||
def test_store_outage_is_a_redacted_503() -> None:
|
||||
client = TestClient(create_app(store=_BrokenStore(), authorizer=_AllowAllAuthorizer()))
|
||||
|
||||
read = client.get("/tenants/t-1")
|
||||
read = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
|
||||
write = client.patch(
|
||||
"/tenants/t-1", headers=HEADERS, json={**BODY, "metadata": {"display_name": "X"}}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,20 @@ from datetime import UTC, datetime
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from tenant_engine.app import create_app
|
||||
from tenant_engine.authz import WriteAuthorizer
|
||||
from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant
|
||||
from tenant_engine.store import InMemoryTenantStore, TenantStore
|
||||
|
||||
|
||||
class _AllowAll(WriteAuthorizer):
|
||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _client(store: TenantStore) -> TestClient:
|
||||
return TestClient(create_app(store=store, authorizer=_AllowAll()))
|
||||
|
||||
|
||||
class _BrokenStore:
|
||||
"""Test double: every active_roles() call raises, simulating an outage."""
|
||||
|
||||
|
|
@ -57,8 +67,8 @@ def _seeded_store() -> InMemoryTenantStore:
|
|||
|
||||
|
||||
def test_cache_read_roles_returns_active_roles() -> None:
|
||||
client = TestClient(create_app(store=_seeded_store()))
|
||||
response = client.get("/tenants/t-binky/roles")
|
||||
client = _client(_seeded_store())
|
||||
response = client.get("/tenants/t-binky/roles", params={"actor": "key-cape"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"tenant_id": "t-binky", "roles": ["CUS"]}
|
||||
|
|
@ -70,23 +80,25 @@ def test_cache_read_roles_resolves_by_identifier_not_only_internal_id() -> None:
|
|||
tenant's profile identifier (the IAM Profile `tenant` claim value), via
|
||||
a URL path segment containing colons -- never the internal tenant_id.
|
||||
"""
|
||||
client = TestClient(create_app(store=_seeded_store()))
|
||||
response = client.get("/tenants/tenant:friendly:binky/roles")
|
||||
client = _client(_seeded_store())
|
||||
response = client.get(
|
||||
"/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"tenant_id": "tenant:friendly:binky", "roles": ["CUS"]}
|
||||
|
||||
|
||||
def test_cache_read_roles_unknown_tenant_is_404() -> None:
|
||||
client = TestClient(create_app(store=_seeded_store()))
|
||||
response = client.get("/tenants/does-not-exist/roles")
|
||||
client = _client(_seeded_store())
|
||||
response = client.get("/tenants/does-not-exist/roles", params={"actor": "key-cape"})
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_live_lookup_roles_returns_active_roles() -> None:
|
||||
client = TestClient(create_app(store=_seeded_store()))
|
||||
response = client.get("/tenants/t-binky/roles/live")
|
||||
client = _client(_seeded_store())
|
||||
response = client.get("/tenants/t-binky/roles/live", params={"actor": "flex-auth"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["roles"] == ["CUS"]
|
||||
|
|
@ -94,9 +106,9 @@ def test_live_lookup_roles_returns_active_roles() -> None:
|
|||
|
||||
def test_live_lookup_fails_closed_on_store_outage() -> None:
|
||||
broken = _BrokenStore(_seeded_store())
|
||||
client = TestClient(create_app(store=broken))
|
||||
client = _client(broken)
|
||||
|
||||
response = client.get("/tenants/t-binky/roles/live")
|
||||
response = client.get("/tenants/t-binky/roles/live", params={"actor": "flex-auth"})
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() != {"tenant_id": "t-binky", "roles": []}, (
|
||||
|
|
@ -106,8 +118,35 @@ def test_live_lookup_fails_closed_on_store_outage() -> None:
|
|||
|
||||
def test_cache_read_also_fails_closed_on_store_outage() -> None:
|
||||
broken = _BrokenStore(_seeded_store())
|
||||
client = TestClient(create_app(store=broken))
|
||||
client = _client(broken)
|
||||
|
||||
response = client.get("/tenants/t-binky/roles")
|
||||
response = client.get("/tenants/t-binky/roles", params={"actor": "key-cape"})
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_unauthorized_reads_are_denied_before_tenant_existence_is_observed() -> None:
|
||||
class _MustNotRead:
|
||||
def get_tenant(self, tenant_id):
|
||||
raise AssertionError(f"store read leaked for {tenant_id}")
|
||||
|
||||
def active_roles(self, tenant_id):
|
||||
raise AssertionError(f"role read leaked for {tenant_id}")
|
||||
|
||||
client = TestClient(create_app(store=_MustNotRead()))
|
||||
for path in (
|
||||
"/tenants/known?actor=unbound",
|
||||
"/tenants/unknown?actor=unbound",
|
||||
"/tenants/known/roles?actor=unbound",
|
||||
"/tenants/unknown/roles/live?actor=unbound",
|
||||
):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error_code"] == "write_denied"
|
||||
|
||||
|
||||
def test_read_requires_an_explicit_actor() -> None:
|
||||
client = _client(_seeded_store())
|
||||
assert client.get("/tenants/t-binky").status_code == 422
|
||||
assert client.get("/tenants/t-binky/roles").status_code == 422
|
||||
assert client.get("/tenants/t-binky/roles/live").status_code == 422
|
||||
|
|
|
|||
|
|
@ -75,13 +75,13 @@ def test_full_write_lifecycle_succeeds_when_authorizer_allows() -> None:
|
|||
)
|
||||
assert granted.status_code == 201
|
||||
|
||||
roles = client.get("/tenants/t-1/roles")
|
||||
roles = client.get("/tenants/t-1/roles", params={"actor": "tenant-engine"})
|
||||
assert roles.json()["roles"] == ["CUS"]
|
||||
|
||||
revoked = client.post("/tenants/t-1/roles/revoke", json={"grant_id": "g-1", "actor": "ops"})
|
||||
assert revoked.status_code == 200
|
||||
|
||||
roles_after = client.get("/tenants/t-1/roles")
|
||||
roles_after = client.get("/tenants/t-1/roles", params={"actor": "tenant-engine"})
|
||||
assert roles_after.json()["roles"] == []
|
||||
|
||||
plan = client.post("/tenants/t-1/plan", json={"plan_id": "plan-x", "actor": "ops"})
|
||||
|
|
|
|||
|
|
@ -11,3 +11,16 @@ def test_health_endpoint() -> None:
|
|||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["service"] == "tenant-engine"
|
||||
assert body["store_backend"] == "memory"
|
||||
|
||||
|
||||
def test_liveness_endpoint_does_not_depend_on_the_store() -> None:
|
||||
class _UnavailableStore:
|
||||
def ping(self) -> None:
|
||||
raise AssertionError("liveness must not touch the store")
|
||||
|
||||
client = TestClient(create_app(store=_UnavailableStore()))
|
||||
response = client.get("/live")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
|
|
|||
33
tests/test_backend_selection.py
Normal file
33
tests/test_backend_selection.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from tenant_engine.config import Settings
|
||||
from tenant_engine.main import _build_store
|
||||
from tenant_engine.sqlite_store import SQLiteTenantStore
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
flex_auth_base_url=None,
|
||||
flex_auth_timeout_seconds=1,
|
||||
host="127.0.0.1",
|
||||
port=8090,
|
||||
)
|
||||
|
||||
|
||||
def test_store_selection_defaults_to_memory_and_selects_sqlite(tmp_path) -> None:
|
||||
assert _build_store(_settings()) is None
|
||||
store = _build_store(replace(_settings(), database_path=str(tmp_path / "tenant.db")))
|
||||
assert isinstance(store, SQLiteTenantStore)
|
||||
|
||||
|
||||
def test_store_selection_refuses_ambiguous_configuration(tmp_path) -> None:
|
||||
with pytest.raises(RuntimeError, match="ambiguous store configuration"):
|
||||
_build_store(
|
||||
replace(
|
||||
_settings(),
|
||||
database_path=str(tmp_path / "tenant.db"),
|
||||
database_url_file=str(tmp_path / "database-url"),
|
||||
)
|
||||
)
|
||||
|
|
@ -9,6 +9,7 @@ kind a single-backend suite would miss.
|
|||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from postgres_backend import clean_postgres_store
|
||||
|
||||
from tenant_engine.domain import Tenant, TenantRetiredError
|
||||
from tenant_engine.guardrail import (
|
||||
|
|
@ -30,11 +31,13 @@ NOW = datetime(2026, 8, 16, 12, 0, tzinfo=UTC)
|
|||
KEY = "spend.monthly"
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory", "sqlite"])
|
||||
@pytest.fixture(params=["memory", "sqlite", "postgres"])
|
||||
def store(request, tmp_path):
|
||||
if request.param == "memory":
|
||||
return InMemoryTenantStore()
|
||||
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
|
||||
if request.param == "sqlite":
|
||||
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
|
||||
return clean_postgres_store(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""TEN-WP-0005-T02/T04: one lifecycle contract, both store backends.
|
||||
"""TEN-WP-0005-T02/T04: one lifecycle contract, every store backend.
|
||||
|
||||
Every test here is parametrised over the in-memory and SQLite stores so the
|
||||
Every test here is parametrised over the in-memory, SQLite, and PostgreSQL stores so the
|
||||
durable backend cannot silently diverge from the reference semantics -- the
|
||||
divergence that matters (CAS, idempotency, retirement guards) is exactly the
|
||||
kind a single-backend suite would miss.
|
||||
|
|
@ -9,6 +9,7 @@ kind a single-backend suite would miss.
|
|||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from postgres_backend import clean_postgres_store
|
||||
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
|
|
@ -30,11 +31,13 @@ from tenant_engine.store import (
|
|||
NOW = datetime(2026, 8, 10, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory", "sqlite"])
|
||||
@pytest.fixture(params=["memory", "sqlite", "postgres"])
|
||||
def store(request, tmp_path):
|
||||
if request.param == "memory":
|
||||
return InMemoryTenantStore()
|
||||
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
|
||||
if request.param == "sqlite":
|
||||
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
|
||||
return clean_postgres_store(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -78,6 +81,22 @@ def test_update_persists_and_bumps_version(store, tenant) -> None:
|
|||
assert store.get_tenant("t-1").display_name == "Binky Ltd"
|
||||
|
||||
|
||||
def test_grouping_mutation_persists(store, tenant) -> None:
|
||||
updated, replayed = store.mutate_tenant(
|
||||
tenant_id="t-1",
|
||||
expected_version=1,
|
||||
mutate=lambda current: current.with_grouping("large", at=NOW),
|
||||
event_type="tenant_grouping_changed",
|
||||
evidence={"actor": "ops", "reason": "growth", "correlation_id": "corr-grouping"},
|
||||
idempotency_key="grouping-large",
|
||||
request_fingerprint="fp-grouping-large",
|
||||
)
|
||||
|
||||
assert replayed is False
|
||||
assert updated.grouping == "large"
|
||||
assert store.get_tenant("t-1").grouping == "large"
|
||||
|
||||
|
||||
def test_stale_version_is_rejected(store, tenant) -> None:
|
||||
_rename(store, key="k1", version=1)
|
||||
|
||||
|
|
|
|||
161
tests/test_transfer.py
Normal file
161
tests/test_transfer.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tenant_engine.sqlite_store import SQLiteTenantStore
|
||||
from tenant_engine.transfer import _digest, _normalise, _parse_expectation, _transfer, _verify
|
||||
|
||||
|
||||
def test_parse_expected_tenant_with_colon_identifier() -> None:
|
||||
assert _parse_expectation("tenant:trial:portalcheck=active:1") == (
|
||||
"tenant:trial:portalcheck",
|
||||
"active",
|
||||
1,
|
||||
)
|
||||
with pytest.raises(ValueError, match="LIFECYCLE"):
|
||||
_parse_expectation("tenant:trial:portalcheck")
|
||||
|
||||
|
||||
def test_normalised_evidence_matches_sqlite_and_postgres_shapes() -> None:
|
||||
sqlite_rows = [
|
||||
{
|
||||
"seq": 1,
|
||||
"event_type": "tenant_created",
|
||||
"tenant_id": "t-1",
|
||||
"at": "2026-08-18T10:00:00+00:00",
|
||||
"payload": '{"grouping":"trial"}',
|
||||
}
|
||||
]
|
||||
postgres_rows = [
|
||||
{
|
||||
"seq": 1,
|
||||
"event_type": "tenant_created",
|
||||
"tenant_id": "t-1",
|
||||
"at": datetime(2026, 8, 18, 10, tzinfo=UTC),
|
||||
"payload": {"grouping": "trial"},
|
||||
}
|
||||
]
|
||||
|
||||
expected = _normalise("events", sqlite_rows)
|
||||
assert expected == _normalise("events", postgres_rows)
|
||||
assert _digest(expected) == _digest(_normalise("events", postgres_rows))
|
||||
|
||||
|
||||
def test_sqlite_transfer_preserves_every_table_and_legacy_null_timestamps(tmp_path) -> None:
|
||||
dsn = os.getenv("TENANT_ENGINE_TEST_DATABASE_URL", "").strip()
|
||||
if not dsn:
|
||||
pytest.skip("set TENANT_ENGINE_TEST_DATABASE_URL for transfer integration")
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
sqlite_path = tmp_path / "tenant.db"
|
||||
sqlite_store = SQLiteTenantStore(str(sqlite_path))
|
||||
with sqlite_store._db: # noqa: SLF001 - migration fixture uses the real schema
|
||||
sqlite_store._db.execute( # noqa: SLF001
|
||||
"""INSERT INTO tenants
|
||||
(tenant_id, identifier, grouping_name, lifecycle, version)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
("t-legacy", "tenant:trial:legacy", "trial", "active", 1),
|
||||
)
|
||||
sqlite_store._db.execute( # noqa: SLF001
|
||||
"INSERT INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"grant-1",
|
||||
"t-legacy",
|
||||
"CUS",
|
||||
"manual_grant",
|
||||
None,
|
||||
"operator",
|
||||
"2026-08-18T10:00:00+00:00",
|
||||
"corr-1",
|
||||
None,
|
||||
),
|
||||
)
|
||||
sqlite_store._db.execute( # noqa: SLF001
|
||||
"INSERT INTO plans VALUES (?, ?, ?)",
|
||||
("t-legacy", "silent-tier", "2026-08-18T10:01:00+00:00"),
|
||||
)
|
||||
sqlite_store._db.execute( # noqa: SLF001
|
||||
"INSERT INTO events (seq, event_type, tenant_id, at, payload) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
7,
|
||||
"tenant_created",
|
||||
"t-legacy",
|
||||
"2026-08-18T10:00:00+00:00",
|
||||
json.dumps({"grouping": "trial"}),
|
||||
),
|
||||
)
|
||||
sqlite_store._db.execute( # noqa: SLF001
|
||||
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
"t-legacy",
|
||||
"idem-1",
|
||||
"sha256:request",
|
||||
json.dumps(
|
||||
{
|
||||
"tenant_id": "t-legacy",
|
||||
"identifier": "tenant:trial:legacy",
|
||||
"grouping_name": "trial",
|
||||
"display_name": None,
|
||||
"contact_email": None,
|
||||
"lifecycle": "active",
|
||||
"version": 1,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
"retired_at": None,
|
||||
"reactivated_at": None,
|
||||
}
|
||||
),
|
||||
"2026-08-18T10:02:00+00:00",
|
||||
),
|
||||
)
|
||||
sqlite_store._db.execute( # noqa: SLF001
|
||||
"INSERT INTO guardrail_overrides VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("t-legacy", "monthly_spend", "money", "250", "EUR", "month"),
|
||||
)
|
||||
sqlite_store._db.execute( # noqa: SLF001
|
||||
"INSERT INTO guardrail_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"change-1",
|
||||
"t-legacy",
|
||||
"monthly_spend",
|
||||
None,
|
||||
json.dumps(
|
||||
{"kind": "money", "amount": "250", "currency": "EUR", "period": "month"}
|
||||
),
|
||||
"operator",
|
||||
"initial ceiling",
|
||||
"corr-2",
|
||||
"2026-08-18T10:03:00+00:00",
|
||||
),
|
||||
)
|
||||
sqlite_store._db.close() # noqa: SLF001
|
||||
|
||||
migration = Path(__file__).parents[1] / "migrations/postgres/0001_tenant_store.sql"
|
||||
source = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
||||
source.row_factory = sqlite3.Row
|
||||
try:
|
||||
with psycopg.connect(dsn, row_factory=dict_row, autocommit=True) as target:
|
||||
target.execute(migration.read_text(encoding="utf-8"))
|
||||
target.execute(
|
||||
"""TRUNCATE guardrail_changes, guardrail_overrides, idempotency_receipts,
|
||||
events, plans, grants, tenants RESTART IDENTITY CASCADE"""
|
||||
)
|
||||
with target.transaction():
|
||||
_transfer(source, target)
|
||||
evidence = _verify(source, target, [("t-legacy", "active", 1)])
|
||||
next_seq = target.execute(
|
||||
"INSERT INTO events (event_type, tenant_id, at, payload) "
|
||||
"VALUES ('after_transfer', 't-legacy', now(), '{}'::jsonb) RETURNING seq"
|
||||
).fetchone()["seq"]
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
assert evidence["tables"]["events"]["rows"] == 1
|
||||
assert evidence["tables"]["guardrail_changes"]["rows"] == 1
|
||||
assert next_seq == 8
|
||||
Loading…
Add table
Add a link
Reference in a new issue