CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema
The foundation of the hosted registry, in canned-prompts so rapp.yaml gets
ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership
stays out of the operations repo.
Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic,
PostgreSQL, in service/ with its own environment. reference/ is deliberately
untouched: it is the format's conformance witness and stays dependency-light,
and the service is a separate consumer of the same package semantics.
CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back
to a local database when its real one is misconfigured is worse than one that
refuses to start.
Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and
/readyz, plus /state/health for fleet consistency. /healthz deliberately checks
nothing beyond the process being up, so a database blip does not restart pods;
/readyz asks the database something it can fail to answer.
Migration 0001 creates package_versions, package_files and index_entries, every
one carrying a tenant key per business-app-service-contract section 1.3 — the
service is single-tenant today, and the key is present so a later consolidation
is a data copy rather than a rewrite. A test asserts every table in the metadata
is tenant-keyed, so adding an unkeyed table fails the suite rather than being
discovered at consolidation time. Uniqueness is (tenant, registry, package_id,
version): registry-scoped because identity is, tenant-scoped so two tenants may
hold the same id.
The schema keeps the format's three things distinct — an immutable package
version, its files as content rather than parsed rows, and an index entry
recording how a version arrived here.
Fixes a bug its own test caught: check_readiness first caught every failure in
one except and reported "database unreachable", so an unmigrated but perfectly
reachable database sent an operator to credentials and networking when the fix
was alembic upgrade. Connectivity and schema are now checked separately.
Service tests 11 passing; reference tests unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 19:57:47 +02:00
|
|
|
"""Health surface.
|
|
|
|
|
|
|
|
|
|
The point of these tests is the negative cases. An endpoint that returns 200
|
|
|
|
|
under every condition tells an orchestrator nothing, and the failure is silent
|
|
|
|
|
exactly when it matters.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from alembic import command
|
|
|
|
|
from alembic.config import Config
|
CANP-WP-0006 T04: publish API
POST /packages takes {registry, source, files}, where files is the same shape
GET /archives returns — so an archive round-trips into a publish without
translation and a mirror is a GET followed by a POST. A test verifies the
round-trip by digest rather than asserting it.
Validation is the reference implementation's, applied to the posted files
materialized in a temporary directory. Only reserved paths and
manifest-referenced files are stored (section 2), path traversal is refused,
identical re-publishes are accepted, and different content under the same
id@version is 409 (section 17).
The identity mechanism, stated plainly rather than implied: a single shared
bearer token proving the caller is the operator of this service. It is not
per-publisher identity — every token holder is indistinguishable — and auth.py
says so where someone might otherwise assume more.
With no token configured the service is read-only. That is the correct default
rather than an inconvenience: section 20.1 asks a registry to refuse
publication into a closed namespace it does not consider the publisher to own,
and an unauthenticated service considers nobody to own anything.
Namespace claims live in namespace_claims (migration 0002) and are enforced
here, which a filesystem registry cannot do at all — but only as precisely as
the identity allows. A closed namespace is protected from anonymous callers; it
cannot be attributed among several publishers. Per-publisher identity is
deferred and is the main thing between this and a registry several people can
publish to.
Migration hygiene found while adding 0002: autogenerate proposed an ALTER
COLUMN TYPE on package_files.package_version_id, because the foreign key's type
was left to inference and compared as a variant against a reflected plain type.
SQLite cannot alter a column type, so 0002 failed halfway — table created,
revision unstamped, the partially-applied state that is worst to debug later.
Fixed at the cause: the column is typed explicitly, and 0001 was corrected
rather than patched over, which is legitimate only because it has never run
outside this repo's tests. alembic check now reports no drift.
Health tests now compute the expected migration head from the script directory
instead of hardcoding it, so adding a migration cannot fail them spuriously.
Service tests 22 -> 33; reference unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 20:30:00 +02:00
|
|
|
from alembic.script import ScriptDirectory
|
CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema
The foundation of the hosted registry, in canned-prompts so rapp.yaml gets
ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership
stays out of the operations repo.
Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic,
PostgreSQL, in service/ with its own environment. reference/ is deliberately
untouched: it is the format's conformance witness and stays dependency-light,
and the service is a separate consumer of the same package semantics.
CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back
to a local database when its real one is misconfigured is worse than one that
refuses to start.
Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and
/readyz, plus /state/health for fleet consistency. /healthz deliberately checks
nothing beyond the process being up, so a database blip does not restart pods;
/readyz asks the database something it can fail to answer.
Migration 0001 creates package_versions, package_files and index_entries, every
one carrying a tenant key per business-app-service-contract section 1.3 — the
service is single-tenant today, and the key is present so a later consolidation
is a data copy rather than a rewrite. A test asserts every table in the metadata
is tenant-keyed, so adding an unkeyed table fails the suite rather than being
discovered at consolidation time. Uniqueness is (tenant, registry, package_id,
version): registry-scoped because identity is, tenant-scoped so two tenants may
hold the same id.
The schema keeps the format's three things distinct — an immutable package
version, its files as content rather than parsed rows, and an index entry
recording how a version arrived here.
Fixes a bug its own test caught: check_readiness first caught every failure in
one except and reported "database unreachable", so an unmigrated but perfectly
reachable database sent an operator to credentials and networking when the fix
was alembic upgrade. Connectivity and schema are now checked separately.
Service tests 11 passing; reference tests unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 19:57:47 +02:00
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
from canned_prompts_service.api import create_app
|
|
|
|
|
from canned_prompts_service.db import check_readiness, make_engine
|
|
|
|
|
from canned_prompts_service.settings import Settings
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
|
|
|
|
|
CANP-WP-0006 T04: publish API
POST /packages takes {registry, source, files}, where files is the same shape
GET /archives returns — so an archive round-trips into a publish without
translation and a mirror is a GET followed by a POST. A test verifies the
round-trip by digest rather than asserting it.
Validation is the reference implementation's, applied to the posted files
materialized in a temporary directory. Only reserved paths and
manifest-referenced files are stored (section 2), path traversal is refused,
identical re-publishes are accepted, and different content under the same
id@version is 409 (section 17).
The identity mechanism, stated plainly rather than implied: a single shared
bearer token proving the caller is the operator of this service. It is not
per-publisher identity — every token holder is indistinguishable — and auth.py
says so where someone might otherwise assume more.
With no token configured the service is read-only. That is the correct default
rather than an inconvenience: section 20.1 asks a registry to refuse
publication into a closed namespace it does not consider the publisher to own,
and an unauthenticated service considers nobody to own anything.
Namespace claims live in namespace_claims (migration 0002) and are enforced
here, which a filesystem registry cannot do at all — but only as precisely as
the identity allows. A closed namespace is protected from anonymous callers; it
cannot be attributed among several publishers. Per-publisher identity is
deferred and is the main thing between this and a registry several people can
publish to.
Migration hygiene found while adding 0002: autogenerate proposed an ALTER
COLUMN TYPE on package_files.package_version_id, because the foreign key's type
was left to inference and compared as a variant against a reflected plain type.
SQLite cannot alter a column type, so 0002 failed halfway — table created,
revision unstamped, the partially-applied state that is worst to debug later.
Fixed at the cause: the column is typed explicitly, and 0001 was corrected
rather than patched over, which is legitimate only because it has never run
outside this repo's tests. alembic check now reports no drift.
Health tests now compute the expected migration head from the script directory
instead of hardcoding it, so adding a migration cannot fail them spuriously.
Service tests 22 -> 33; reference unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 20:30:00 +02:00
|
|
|
def expected_head() -> str:
|
|
|
|
|
"""Computed, not hardcoded: a new migration must not fail these tests."""
|
|
|
|
|
config = Config(str(ROOT / "alembic.ini"))
|
|
|
|
|
config.set_main_option("script_location", str(ROOT / "migrations"))
|
|
|
|
|
return ScriptDirectory.from_config(config).get_current_head()
|
|
|
|
|
|
|
|
|
|
|
CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema
The foundation of the hosted registry, in canned-prompts so rapp.yaml gets
ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership
stays out of the operations repo.
Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic,
PostgreSQL, in service/ with its own environment. reference/ is deliberately
untouched: it is the format's conformance witness and stays dependency-light,
and the service is a separate consumer of the same package semantics.
CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back
to a local database when its real one is misconfigured is worse than one that
refuses to start.
Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and
/readyz, plus /state/health for fleet consistency. /healthz deliberately checks
nothing beyond the process being up, so a database blip does not restart pods;
/readyz asks the database something it can fail to answer.
Migration 0001 creates package_versions, package_files and index_entries, every
one carrying a tenant key per business-app-service-contract section 1.3 — the
service is single-tenant today, and the key is present so a later consolidation
is a data copy rather than a rewrite. A test asserts every table in the metadata
is tenant-keyed, so adding an unkeyed table fails the suite rather than being
discovered at consolidation time. Uniqueness is (tenant, registry, package_id,
version): registry-scoped because identity is, tenant-scoped so two tenants may
hold the same id.
The schema keeps the format's three things distinct — an immutable package
version, its files as content rather than parsed rows, and an index entry
recording how a version arrived here.
Fixes a bug its own test caught: check_readiness first caught every failure in
one except and reported "database unreachable", so an unmigrated but perfectly
reachable database sent an operator to credentials and networking when the fix
was alembic upgrade. Connectivity and schema are now checked separately.
Service tests 11 passing; reference tests unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 19:57:47 +02:00
|
|
|
def migrated_url(tmp_path: Path) -> str:
|
|
|
|
|
url = f"sqlite:///{tmp_path / 'svc.db'}"
|
|
|
|
|
config = Config(str(ROOT / "alembic.ini"))
|
|
|
|
|
config.set_main_option("script_location", str(ROOT / "migrations"))
|
|
|
|
|
config.set_main_option("sqlalchemy.url", url)
|
|
|
|
|
command.upgrade(config, "head")
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def ready_client(tmp_path: Path) -> TestClient:
|
|
|
|
|
url = migrated_url(tmp_path)
|
|
|
|
|
return TestClient(create_app(Settings(database_url=url), make_engine(url)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_healthz_is_up_without_a_database() -> None:
|
|
|
|
|
"""Liveness must not depend on the database, or a DB blip restarts pods."""
|
|
|
|
|
client = TestClient(create_app(Settings(), None))
|
|
|
|
|
assert client.get("/healthz").json() == {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_readyz_fails_without_a_database() -> None:
|
|
|
|
|
client = TestClient(create_app(Settings(), None))
|
|
|
|
|
response = client.get("/readyz")
|
|
|
|
|
assert response.status_code == 503
|
|
|
|
|
assert response.json()["ready"] is False
|
|
|
|
|
assert "no database" in response.json()["detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_readyz_fails_when_the_database_is_unreachable(tmp_path: Path) -> None:
|
|
|
|
|
engine = make_engine("sqlite:////nonexistent/dir/does-not-exist.db")
|
|
|
|
|
client = TestClient(create_app(Settings(database_url="x"), engine))
|
|
|
|
|
response = client.get("/readyz")
|
|
|
|
|
assert response.status_code == 503
|
|
|
|
|
assert response.json()["ready"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_readyz_fails_when_the_schema_is_not_migrated(tmp_path: Path) -> None:
|
|
|
|
|
"""Reachable but unmigrated is not ready — it would 500 on the first query."""
|
|
|
|
|
engine = make_engine(f"sqlite:///{tmp_path / 'empty.db'}")
|
|
|
|
|
client = TestClient(create_app(Settings(database_url="x"), engine))
|
|
|
|
|
response = client.get("/readyz")
|
|
|
|
|
assert response.status_code == 503
|
|
|
|
|
assert response.json()["detail"] == "schema not migrated"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_readyz_reports_the_migration_when_ready(ready_client: TestClient) -> None:
|
|
|
|
|
body = ready_client.get("/readyz").json()
|
|
|
|
|
assert body["ready"] is True
|
CANP-WP-0006 T04: publish API
POST /packages takes {registry, source, files}, where files is the same shape
GET /archives returns — so an archive round-trips into a publish without
translation and a mirror is a GET followed by a POST. A test verifies the
round-trip by digest rather than asserting it.
Validation is the reference implementation's, applied to the posted files
materialized in a temporary directory. Only reserved paths and
manifest-referenced files are stored (section 2), path traversal is refused,
identical re-publishes are accepted, and different content under the same
id@version is 409 (section 17).
The identity mechanism, stated plainly rather than implied: a single shared
bearer token proving the caller is the operator of this service. It is not
per-publisher identity — every token holder is indistinguishable — and auth.py
says so where someone might otherwise assume more.
With no token configured the service is read-only. That is the correct default
rather than an inconvenience: section 20.1 asks a registry to refuse
publication into a closed namespace it does not consider the publisher to own,
and an unauthenticated service considers nobody to own anything.
Namespace claims live in namespace_claims (migration 0002) and are enforced
here, which a filesystem registry cannot do at all — but only as precisely as
the identity allows. A closed namespace is protected from anonymous callers; it
cannot be attributed among several publishers. Per-publisher identity is
deferred and is the main thing between this and a registry several people can
publish to.
Migration hygiene found while adding 0002: autogenerate proposed an ALTER
COLUMN TYPE on package_files.package_version_id, because the foreign key's type
was left to inference and compared as a variant against a reflected plain type.
SQLite cannot alter a column type, so 0002 failed halfway — table created,
revision unstamped, the partially-applied state that is worst to debug later.
Fixed at the cause: the column is typed explicitly, and 0001 was corrected
rather than patched over, which is legitimate only because it has never run
outside this repo's tests. alembic check now reports no drift.
Health tests now compute the expected migration head from the script directory
instead of hardcoding it, so adding a migration cannot fail them spuriously.
Service tests 22 -> 33; reference unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 20:30:00 +02:00
|
|
|
assert body["migration"] == expected_head()
|
CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema
The foundation of the hosted registry, in canned-prompts so rapp.yaml gets
ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership
stays out of the operations repo.
Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic,
PostgreSQL, in service/ with its own environment. reference/ is deliberately
untouched: it is the format's conformance witness and stays dependency-light,
and the service is a separate consumer of the same package semantics.
CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back
to a local database when its real one is misconfigured is worse than one that
refuses to start.
Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and
/readyz, plus /state/health for fleet consistency. /healthz deliberately checks
nothing beyond the process being up, so a database blip does not restart pods;
/readyz asks the database something it can fail to answer.
Migration 0001 creates package_versions, package_files and index_entries, every
one carrying a tenant key per business-app-service-contract section 1.3 — the
service is single-tenant today, and the key is present so a later consolidation
is a data copy rather than a rewrite. A test asserts every table in the metadata
is tenant-keyed, so adding an unkeyed table fails the suite rather than being
discovered at consolidation time. Uniqueness is (tenant, registry, package_id,
version): registry-scoped because identity is, tenant-scoped so two tenants may
hold the same id.
The schema keeps the format's three things distinct — an immutable package
version, its files as content rather than parsed rows, and an index entry
recording how a version arrived here.
Fixes a bug its own test caught: check_readiness first caught every failure in
one except and reported "database unreachable", so an unmigrated but perfectly
reachable database sent an operator to credentials and networking when the fix
was alembic upgrade. Connectivity and schema are now checked separately.
Service tests 11 passing; reference tests unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 19:57:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_state_health_matches_the_fleet_shape(ready_client: TestClient) -> None:
|
|
|
|
|
body = ready_client.get("/state/health").json()
|
|
|
|
|
assert body["status"] == "ok"
|
|
|
|
|
assert body["service"] == "canned-prompts"
|
|
|
|
|
assert body["db"] == "connected"
|
CANP-WP-0006 T04: publish API
POST /packages takes {registry, source, files}, where files is the same shape
GET /archives returns — so an archive round-trips into a publish without
translation and a mirror is a GET followed by a POST. A test verifies the
round-trip by digest rather than asserting it.
Validation is the reference implementation's, applied to the posted files
materialized in a temporary directory. Only reserved paths and
manifest-referenced files are stored (section 2), path traversal is refused,
identical re-publishes are accepted, and different content under the same
id@version is 409 (section 17).
The identity mechanism, stated plainly rather than implied: a single shared
bearer token proving the caller is the operator of this service. It is not
per-publisher identity — every token holder is indistinguishable — and auth.py
says so where someone might otherwise assume more.
With no token configured the service is read-only. That is the correct default
rather than an inconvenience: section 20.1 asks a registry to refuse
publication into a closed namespace it does not consider the publisher to own,
and an unauthenticated service considers nobody to own anything.
Namespace claims live in namespace_claims (migration 0002) and are enforced
here, which a filesystem registry cannot do at all — but only as precisely as
the identity allows. A closed namespace is protected from anonymous callers; it
cannot be attributed among several publishers. Per-publisher identity is
deferred and is the main thing between this and a registry several people can
publish to.
Migration hygiene found while adding 0002: autogenerate proposed an ALTER
COLUMN TYPE on package_files.package_version_id, because the foreign key's type
was left to inference and compared as a variant against a reflected plain type.
SQLite cannot alter a column type, so 0002 failed halfway — table created,
revision unstamped, the partially-applied state that is worst to debug later.
Fixed at the cause: the column is typed explicitly, and 0001 was corrected
rather than patched over, which is legitimate only because it has never run
outside this repo's tests. alembic check now reports no drift.
Health tests now compute the expected migration head from the script directory
instead of hardcoding it, so adding a migration cannot fail them spuriously.
Service tests 22 -> 33; reference unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 20:30:00 +02:00
|
|
|
assert body["migration"] == expected_head()
|
CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema
The foundation of the hosted registry, in canned-prompts so rapp.yaml gets
ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership
stays out of the operations repo.
Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic,
PostgreSQL, in service/ with its own environment. reference/ is deliberately
untouched: it is the format's conformance witness and stays dependency-light,
and the service is a separate consumer of the same package semantics.
CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back
to a local database when its real one is misconfigured is worse than one that
refuses to start.
Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and
/readyz, plus /state/health for fleet consistency. /healthz deliberately checks
nothing beyond the process being up, so a database blip does not restart pods;
/readyz asks the database something it can fail to answer.
Migration 0001 creates package_versions, package_files and index_entries, every
one carrying a tenant key per business-app-service-contract section 1.3 — the
service is single-tenant today, and the key is present so a later consolidation
is a data copy rather than a rewrite. A test asserts every table in the metadata
is tenant-keyed, so adding an unkeyed table fails the suite rather than being
discovered at consolidation time. Uniqueness is (tenant, registry, package_id,
version): registry-scoped because identity is, tenant-scoped so two tenants may
hold the same id.
The schema keeps the format's three things distinct — an immutable package
version, its files as content rather than parsed rows, and an index entry
recording how a version arrived here.
Fixes a bug its own test caught: check_readiness first caught every failure in
one except and reported "database unreachable", so an unmigrated but perfectly
reachable database sent an operator to credentials and networking when the fix
was alembic upgrade. Connectivity and schema are now checked separately.
Service tests 11 passing; reference tests unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 19:57:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_state_health_degrades_rather_than_lying() -> None:
|
|
|
|
|
client = TestClient(create_app(Settings(), None))
|
|
|
|
|
response = client.get("/state/health")
|
|
|
|
|
assert response.status_code == 503
|
|
|
|
|
assert response.json()["status"] == "degraded"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_have_no_database_fallback() -> None:
|
|
|
|
|
"""Falling back to a local database when misconfigured hides the mistake."""
|
|
|
|
|
assert Settings().database_url == ""
|
|
|
|
|
assert Settings().configured is False
|
2026-09-06 21:45:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_database_url_may_come_from_a_file(tmp_path: Path) -> None:
|
|
|
|
|
"""A mounted secret should stay a file, not become an env var."""
|
|
|
|
|
secret = tmp_path / "url"
|
|
|
|
|
secret.write_text("sqlite:///from-file.db\n", encoding="utf-8")
|
|
|
|
|
settings = Settings(database_url_file=str(secret))
|
|
|
|
|
assert settings.configured is True
|
|
|
|
|
assert settings.resolved_database_url == "sqlite:///from-file.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_file_wins_over_env_when_both_are_set(tmp_path: Path) -> None:
|
|
|
|
|
"""A rotated secret must take effect, not be shadowed by a stale env var."""
|
|
|
|
|
secret = tmp_path / "url"
|
|
|
|
|
secret.write_text("sqlite:///from-file.db", encoding="utf-8")
|
|
|
|
|
settings = Settings(database_url="sqlite:///from-env.db", database_url_file=str(secret))
|
|
|
|
|
assert settings.resolved_database_url == "sqlite:///from-file.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unreadable_secret_file_fails_loudly(tmp_path: Path) -> None:
|
|
|
|
|
settings = Settings(database_url_file=str(tmp_path / "missing"))
|
|
|
|
|
with pytest.raises(RuntimeError, match="cannot read secret file"):
|
|
|
|
|
_ = settings.resolved_database_url
|
Service fixes found by the first real deployment
Three defects the test suite could not have caught, because each needed a real
cluster, a mounted secret, or a live PostgreSQL.
env.py read database_url rather than resolved_database_url. `configured` was
true because a file was set, and the field it then read was empty — so Alembic
received an empty URL and the migration could never run in the cluster. The
value is also now escaped for ConfigParser interpolation, since a `%` in a
generated password would otherwise raise at credential rotation, which is the
worst time to find out.
SET ROLE opened an implicit transaction that Alembic then nested inside rather
than owning, so it never committed and leaving the connection block rolled
everything back. Alembic logged "Running upgrade" for every revision against a
database that stayed empty. SET ROLE is session-scoped, so committing
immediately ends the implicit transaction without discarding the role.
A missing optional publish-token file was treated as a hard failure. The
absence is the documented read-only posture — the secret is mounted optional
and deliberately not issued — so treating it as a fault turned an intended
state into a 500 rather than the 503 that explains it. `required` now separates
the two cases: a missing database URL still fails loudly, because there the
silence would hide a real fault.
Migrations also assume the durable owner role rather than creating objects as
the leased migration login, per the rapp-postgres database-owner boundary. The
role name is validated against an identifier pattern because SET ROLE cannot be
parameterised.
Service tests 36 -> 47.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-08 08:56:59 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_absent_publish_token_file_means_read_only_not_broken(tmp_path: Path) -> None:
|
|
|
|
|
"""The token secret is mounted optional and deliberately not issued. Its
|
|
|
|
|
absence is the documented read-only posture, not a fault — treating it as
|
|
|
|
|
one returned 500 from /packages instead of a 503 explaining why."""
|
|
|
|
|
settings = Settings(publish_token_file=str(tmp_path / "absent"))
|
|
|
|
|
assert settings.resolved_publish_token == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_absent_database_file_still_fails_loudly(tmp_path: Path) -> None:
|
|
|
|
|
"""The database URL is required; silence there would hide a real fault."""
|
|
|
|
|
settings = Settings(database_url_file=str(tmp_path / "absent"))
|
|
|
|
|
with pytest.raises(RuntimeError, match="cannot read secret file"):
|
|
|
|
|
_ = settings.resolved_database_url
|
Survive credential rotation: re-read the lease for every connection
The deployment ran for one lease window and then sat unready for eight hours.
Platform credentials are 30-minute leases, not passwords: the service read the
mounted URL once at start-up, so External Secrets kept the file current while
the engine held the URL it booted with, and every reconnection after the first
expiry used a credential the database had already revoked.
make_engine now takes an optional refresh callable, invoked by a do_connect
hook each time the pool opens a connection, and pool_recycle is 900s so a
pooled connection is retired well inside the lease. Only username and password
are taken from the refreshed URL — host, port and database come from the engine,
so a malformed refresh cannot silently redirect the service somewhere else.
Two things behaved correctly and are worth keeping. /readyz reported the real
cause, "database unreachable: OperationalError", rather than a generic failure.
And liveness stayed independent of the database, so the pod was never
restart-looped: it was alive, unable to serve, and said so. Pointing liveness at
a database-dependent path would have masked this as a crash loop.
Service tests 47 -> 49, including one asserting pool_recycle stays inside the
shortest lease the platform issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-08 10:11:35 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_engine_rereads_credentials_on_each_connection(tmp_path: Path) -> None:
|
|
|
|
|
"""Platform credentials are 30-minute leases, not passwords. Reading the
|
|
|
|
|
file once at start-up worked for one lease window and then failed
|
|
|
|
|
permanently — External Secrets kept the file current while the engine kept
|
|
|
|
|
the URL it booted with."""
|
|
|
|
|
secret = tmp_path / "url"
|
|
|
|
|
secret.write_text(f"sqlite:///{tmp_path / 'a.db'}", encoding="utf-8")
|
|
|
|
|
settings = Settings(database_url_file=str(secret))
|
|
|
|
|
|
|
|
|
|
seen: list[str] = []
|
|
|
|
|
engine = make_engine(
|
|
|
|
|
settings.resolved_database_url,
|
|
|
|
|
refresh=lambda: (seen.append(settings.resolved_database_url) or settings.resolved_database_url),
|
|
|
|
|
)
|
|
|
|
|
with engine.connect():
|
|
|
|
|
pass
|
|
|
|
|
assert seen, "the refresh hook must run when the pool opens a connection"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pool_recycle_is_shorter_than_the_shortest_lease() -> None:
|
|
|
|
|
"""30-minute runtime lease; a pooled connection must be retired first."""
|
|
|
|
|
from canned_prompts_service.db import POOL_RECYCLE_SECONDS
|
|
|
|
|
|
|
|
|
|
assert POOL_RECYCLE_SECONDS < 30 * 60
|