6 Commits

13 changed files with 909 additions and 51 deletions

View File

@@ -5,7 +5,11 @@ from logging.config import fileConfig
from alembic import context from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata try:
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
except ModuleNotFoundError as exc:
if exc.name != "govoplan_access":
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan from govoplan_core.core.migrations import migration_metadata_plan

View File

@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
``` ```
The integration check runs migrations and startup smoke checks across the The integration check runs migrations and startup smoke checks across the
standard module permutations. `--reset-schema` is destructive and belongs only standard module permutations. It first requires the retirement atomicity proof,
on throwaway databases. using Files' real secret-owning provider and Audit's persistent recorder. That
proof uses only random, test-owned schemas and cleans them afterward; it does
not reset `public`. `--reset-schema` is destructive and belongs only on
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
release evidence.
### Broker And Workers ### Broker And Workers

View File

@@ -788,7 +788,18 @@ tools/checks/postgres-integration-check.py \
The script checks migrations and `/health` startup for core-only, files-only, The script checks migrations and `/health` startup for core-only, files-only,
mail-only, campaign-only, campaign+files, campaign+mail, and full-product mail-only, campaign-only, campaign+files, campaign+mail, and full-product
module sets. `--reset-schema` is destructive and must only be used against a module sets. `--reset-schema` is destructive and must only be used against a
throwaway database. throwaway database. Before those permutations, the required Core proof runs in
random, test-owned schemas without modifying `public`. It exercises Files' real
credential-owning retirement provider and proves that credential scrubbing,
non-secret audit
insertion, and table retirement commit together; database-injected audit and
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
captured backend process IDs also prove that each `DROP TABLE` uses the
installer Session connection instead of waiting through a second connection.
The meta check enables the release-gate flag so missing PostgreSQL configuration
or full-stack test packages are a hard failure; ordinary Core-only test discovery
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
collecting release evidence.
## Migration Baselines ## Migration Baselines

View File

@@ -415,6 +415,254 @@
"release": "0.1.12", "release": "0.1.12",
"squash_policy": "reviewed-manual", "squash_policy": "reviewed-manual",
"track": "release" "track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T08:39:02Z",
"release": "0.1.13",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T18:15:01Z",
"release": "0.1.14",
"squash_policy": "reviewed-manual",
"track": "release"
} }
], ],
"version": 1 "version": 1

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-core" name = "govoplan-core"
version = "0.1.12" version = "0.1.14"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -27,6 +27,12 @@ where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
govoplan_core = ["py.typed"] govoplan_core = ["py.typed"]
[tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"]
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
[project.scripts] [project.scripts]
govoplan-config = "govoplan_core.commands.config:main" govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main" govoplan-devserver = "govoplan_core.devserver:main"

View File

@@ -7,6 +7,7 @@ import logging
import os import os
from pathlib import Path from pathlib import Path
import re import re
import sysconfig
from typing import Any from typing import Any
from alembic import command from alembic import command
@@ -462,11 +463,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
def _repo_root() -> Path: def _repo_root() -> Path:
packaged_root = Path(__file__).resolve().parents[3] packaged_root = Path(__file__).resolve().parents[3]
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT") configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [ candidates = [
Path(configured).expanduser() if configured else None, Path(configured).expanduser() if configured else None,
Path.cwd(), Path.cwd(),
packaged_root, packaged_root,
installed_runtime_root,
] ]
for candidate in candidates: for candidate in candidates:
if candidate is None: if candidate is None:

View File

@@ -4211,18 +4211,17 @@ class ApiSmokeTests(unittest.TestCase):
"enqueue_imap_task": False, "enqueue_imap_task": False,
}, },
) )
self.assertEqual(sent.status_code, 200, sent.text) self.assertEqual(sent.status_code, 422, sent.text)
self.assertEqual(sent.json()["result"]["sent_count"], 0) self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
self.assertEqual(sent.json()["result"]["failed_count"], 1)
self.assertIn("changed after this campaign was built", sent.json()["result"]["results"][0]["message"])
with SessionLocal() as session: with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one() job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.send_status, "failed_permanent") self.assertEqual(job.queue_status, "draft")
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one() self.assertEqual(job.send_status, "not_queued")
self.assertIn("changed after this campaign was built", attempt.error_message) self.assertEqual(
self.assertNotIn("smtp.example.invalid", attempt.error_message) session.query(SendAttempt).filter(SendAttempt.job_id == job.id).count(),
self.assertIsNone(attempt.smtp_response) 0,
)
def test_send_now_sends_exact_generated_eml_bytes(self) -> None: def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
headers, _ = self._login() headers, _ = self._login()
@@ -4286,11 +4285,15 @@ class ApiSmokeTests(unittest.TestCase):
"enqueue_imap_task": False, "enqueue_imap_task": False,
}, },
) )
self.assertEqual(sent.status_code, 200, sent.text) self.assertEqual(sent.status_code, 422, sent.text)
self.assertEqual(sent.json()["result"]["failed_count"], 1) self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
self.assertIn("Generated EML", sent.json()["result"]["results"][0]["message"])
self.assertEqual(list_records(kind="smtp"), []) self.assertEqual(list_records(kind="smtp"), [])
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.queue_status, "draft")
self.assertEqual(job.send_status, "not_queued")
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None: def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
headers, _ = self._login() headers, _ = self._login()
from govoplan_mail.backend.dev.mock_mailbox import set_failures from govoplan_mail.backend.dev.mock_mailbox import set_failures
@@ -4349,7 +4352,8 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(sent.status_code, 200, sent.text) self.assertEqual(sent.status_code, 200, sent.text)
result = sent.json()["result"] result = sent.json()["result"]
self.assertEqual(result["sent_count"], 1, sent.text) self.assertEqual(result["sent_count"], 1, sent.text)
self.assertIn("refused recipients", result["results"][0]["message"]) self.assertEqual(result["results"][0]["status"], "smtp_accepted")
self.assertNotIn("message", result["results"][0])
finally: finally:
set_failures(smtp_reject_recipients_containing=None) set_failures(smtp_reject_recipients_containing=None)

View File

@@ -0,0 +1,435 @@
from __future__ import annotations
import json
import os
import unittest
from uuid import uuid4
from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.exc import DBAPIError
from sqlalchemy.orm import Session
from sqlalchemy.schema import CreateSchema, DropSchema
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleContext
from govoplan_core.core.runtime import clear_runtime, configure_runtime, get_runtime_context
from govoplan_core.db.base import Base
_CREDENTIAL_ID = "retirement-credential"
_TENANT_ID = "retirement-tenant"
_PRIVATE_FIXTURE_VALUES = (
"opaque-password-ciphertext",
"opaque-token-ciphertext",
"vault:test-only:credential",
"private-test-metadata",
)
_ROW_COUNT_QUERIES = {
"audit_log": text("SELECT count(*) FROM audit_log"),
"core_change_sequence": text("SELECT count(*) FROM core_change_sequence"),
"retirement_scrub_probe": text("SELECT count(*) FROM retirement_scrub_probe"),
}
def _postgres_database_url() -> str:
candidate = (
os.environ.get("GOVOPLAN_POSTGRES_DATABASE_URL")
or os.environ.get("DATABASE_URL")
or ""
).strip()
return candidate if candidate.startswith("postgresql") else ""
def _proof_is_required() -> bool:
return os.environ.get("GOVOPLAN_REQUIRE_POSTGRES_RETIREMENT_PROOF", "").strip().casefold() in {
"1",
"true",
"yes",
"on",
}
class _AuditCapabilityRegistry:
def __init__(self, recorder: object) -> None:
self._recorder = recorder
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_AUDIT_RECORDER
def require_capability(self, name: str) -> object:
if not self.has_capability(name):
raise LookupError(name)
return self._recorder
class PostgreSQLRetirementAtomicityTests(unittest.TestCase):
"""Production-reference proof for destructive module retirement.
Every test owns a random PostgreSQL schema and removes it afterward. The
fixture stores only synthetic credential material. Assertions and audit
inspection expose booleans and allow-listed metadata, never stored values
or the database URL.
"""
def setUp(self) -> None:
database_url = _postgres_database_url()
if not database_url:
if _proof_is_required():
self.fail("the required PostgreSQL retirement proof has no PostgreSQL database")
self.skipTest("a PostgreSQL integration database is not configured")
try:
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_audit.backend.db.models import AuditLog
from govoplan_audit.backend.recording import SqlAuditRecorder
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
from govoplan_files.backend.manifest import manifest as files_manifest
except ModuleNotFoundError as exc:
if _proof_is_required():
self.fail(f"the required PostgreSQL retirement proof is missing package: {exc.name}")
self.skipTest(f"the full-stack integration packages are not installed: {exc.name}")
self.audit_log_model = AuditLog
self.credential_model = FileConnectorCredential
self.profile_model = FileConnectorProfile
self.files_manifest = files_manifest
self.previous_runtime = get_runtime_context()
self.admin_engine: Engine | None = None
self.engine: Engine | None = None
self.schema_name = f"govoplan_retirement_{uuid4().hex}"
self.capture_retirement_ddl = False
self.retirement_ddl_backend_pids: list[int] = []
self.ddl_listener_registered = False
try:
self.admin_engine = create_engine(database_url, pool_pre_ping=True)
with self.admin_engine.begin() as connection:
if connection.dialect.name != "postgresql":
self.skipTest("the configured integration database is not PostgreSQL")
connection.execute(CreateSchema(self.schema_name))
self.engine = create_engine(
database_url,
pool_pre_ping=True,
pool_size=5,
max_overflow=0,
connect_args={
"options": (
f"-csearch_path={self.schema_name},pg_catalog "
"-clock_timeout=500ms -cstatement_timeout=10s "
"-capplication_name=govoplan-retirement-atomicity"
),
},
)
event.listen(self.engine, "before_cursor_execute", self._capture_ddl_connection)
self.ddl_listener_registered = True
self._create_fixture_schema()
self._seed_credential()
recorder = SqlAuditRecorder()
configure_runtime(ModuleContext(registry=_AuditCapabilityRegistry(recorder), settings=object()))
except Exception:
self._cleanup()
raise
def tearDown(self) -> None:
self._cleanup()
def test_scrub_audit_and_table_retirement_commit_together(self) -> None:
assert self.engine is not None
with Session(self.engine) as session:
installer_backend_pid = self._session_backend_pid(session)
plan = self._retirement_plan(session)
self._execute_retirement(session, plan)
session.commit()
self.assertTrue(self.retirement_ddl_backend_pids, "the retirement provider emitted no DROP TABLE statement")
self.assertEqual(
{installer_backend_pid},
set(self.retirement_ddl_backend_pids),
"destructive DDL did not use the installer Session connection",
)
with self.engine.connect() as connection:
self.assertFalse(inspect(connection).has_table(self.credential_model.__tablename__))
self.assertFalse(inspect(connection).has_table(self.profile_model.__tablename__))
probe = connection.execute(text(
"SELECT password_removed, token_removed, username_removed, "
"secret_reference_removed, metadata_removed "
"FROM retirement_scrub_probe"
)).mappings().one()
self.assertTrue(all(probe.values()), "the credential scrub probe did not observe every removal")
audit_rows = connection.execute(text(
"SELECT action, object_type, object_id, details "
"FROM audit_log ORDER BY created_at, id"
)).mappings().all()
self.assertEqual(1, len(audit_rows))
audit_row = audit_rows[0]
self.assertEqual("files.connector_credential_deleted", audit_row["action"])
self.assertEqual("file_connector_credential", audit_row["object_type"])
self.assertEqual(_CREDENTIAL_ID, audit_row["object_id"])
self._assert_non_secret_audit_details(dict(audit_row["details"] or {}))
self.assertEqual(1, self._row_count(connection, "core_change_sequence"))
def test_database_audit_failure_rolls_back_scrub_and_audit_state(self) -> None:
assert self.engine is not None
with self.engine.begin() as connection:
connection.exec_driver_sql(
"""
CREATE FUNCTION reject_retirement_audit() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.action = 'files.connector_credential_deleted' THEN
RAISE EXCEPTION 'injected retirement audit failure';
END IF;
RETURN NEW;
END;
$$
"""
)
connection.exec_driver_sql(
"""
CREATE TRIGGER reject_retirement_audit_insert
BEFORE INSERT ON audit_log
FOR EACH ROW EXECUTE FUNCTION reject_retirement_audit()
"""
)
with Session(self.engine) as session:
self._session_backend_pid(session)
plan = self._retirement_plan(session)
with self.assertRaises(DBAPIError) as caught:
self._execute_retirement(session, plan)
self.assertEqual("P0001", getattr(caught.exception.orig, "sqlstate", None))
session.rollback()
self.assertEqual([], self.retirement_ddl_backend_pids, "DDL ran after the injected audit failure")
self._assert_failed_retirement_rolled_back()
def test_database_ddl_failure_rolls_back_scrub_audit_and_prior_drop(self) -> None:
assert self.engine is not None
with self.engine.begin() as connection:
connection.exec_driver_sql(
"""
CREATE TABLE retirement_drop_guard (
id bigint PRIMARY KEY,
credential_id varchar(255) NOT NULL
REFERENCES file_connector_credentials(id)
)
"""
)
connection.execute(
text("INSERT INTO retirement_drop_guard (id, credential_id) VALUES (1, :credential_id)"),
{"credential_id": _CREDENTIAL_ID},
)
with Session(self.engine) as session:
installer_backend_pid = self._session_backend_pid(session)
plan = self._retirement_plan(session)
with self.assertRaises(DBAPIError) as caught:
self._execute_retirement(session, plan)
self.assertEqual("2BP01", getattr(caught.exception.orig, "sqlstate", None))
session.rollback()
self.assertTrue(self.retirement_ddl_backend_pids, "the injected DDL failure was not reached")
self.assertEqual(
{installer_backend_pid},
set(self.retirement_ddl_backend_pids),
"destructive DDL did not use the installer Session connection",
)
self._assert_failed_retirement_rolled_back()
def _create_fixture_schema(self) -> None:
assert self.engine is not None
from govoplan_core.core.change_sequence import ChangeSequenceEntry
with self.engine.begin() as connection:
# Audit and Files reference Access actors. The proof does not need
# actor rows, so small FK targets keep its schema bounded.
connection.exec_driver_sql("CREATE TABLE access_users (id varchar(36) PRIMARY KEY)")
connection.exec_driver_sql("CREATE TABLE access_api_keys (id varchar(36) PRIMARY KEY)")
Base.metadata.create_all(
bind=connection,
tables=[
ChangeSequenceEntry.__table__,
self.audit_log_model.__table__,
self.credential_model.__table__,
self.profile_model.__table__,
],
)
connection.exec_driver_sql(
"""
CREATE TABLE retirement_scrub_probe (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
password_removed boolean NOT NULL,
token_removed boolean NOT NULL,
username_removed boolean NOT NULL,
secret_reference_removed boolean NOT NULL,
metadata_removed boolean NOT NULL
)
"""
)
connection.exec_driver_sql(
"""
CREATE FUNCTION record_retirement_scrub() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO retirement_scrub_probe (
password_removed,
token_removed,
username_removed,
secret_reference_removed,
metadata_removed
) VALUES (
OLD.password_encrypted IS NOT NULL AND NEW.password_encrypted IS NULL,
OLD.token_encrypted IS NOT NULL AND NEW.token_encrypted IS NULL,
OLD.username IS NOT NULL AND NEW.username IS NULL,
OLD.secret_ref IS NOT NULL AND NEW.secret_ref IS NULL,
OLD.metadata IS NOT NULL AND NEW.metadata::jsonb = '{}'::jsonb
);
RETURN NEW;
END;
$$
"""
)
connection.exec_driver_sql(
"""
CREATE TRIGGER record_retirement_credential_scrub
BEFORE UPDATE ON file_connector_credentials
FOR EACH ROW EXECUTE FUNCTION record_retirement_scrub()
"""
)
def _seed_credential(self) -> None:
assert self.engine is not None
with Session(self.engine) as session:
session.add(self.credential_model(
id=_CREDENTIAL_ID,
tenant_id=_TENANT_ID,
scope_type="tenant",
scope_id=_TENANT_ID,
label="Retirement integration credential",
provider="webdav",
enabled=True,
credential_mode="basic",
username="integration-user",
password_encrypted=_PRIVATE_FIXTURE_VALUES[0],
token_encrypted=_PRIVATE_FIXTURE_VALUES[1],
password_env="INTEGRATION_PASSWORD",
token_env="INTEGRATION_TOKEN",
secret_ref=_PRIVATE_FIXTURE_VALUES[2],
policy={},
metadata_={"private_hint": _PRIVATE_FIXTURE_VALUES[3]},
))
session.commit()
def _retirement_plan(self, session: Session) -> MigrationRetirementPlan:
migration = self.files_manifest.migration_spec
self.assertIsNotNone(migration)
assert migration is not None
self.assertIsNotNone(migration.retirement_provider)
assert migration.retirement_provider is not None
plan = migration.retirement_provider(session, "files")
self.assertTrue(plan.destroy_data_supported)
self.assertIsNotNone(plan.destroy_data_executor)
return plan
def _execute_retirement(self, session: Session, plan: MigrationRetirementPlan) -> None:
executor = plan.destroy_data_executor
self.assertIsNotNone(executor)
self.capture_retirement_ddl = True
try:
executor(session, "files")
finally:
self.capture_retirement_ddl = False
def _capture_ddl_connection(
self,
connection: Connection,
_cursor: object,
statement: str,
_parameters: object,
_context: object,
_executemany: bool,
) -> None:
if not self.capture_retirement_ddl or not statement.lstrip().upper().startswith("DROP TABLE"):
return
self.retirement_ddl_backend_pids.append(self._connection_backend_pid(connection))
def _session_backend_pid(self, session: Session) -> int:
connection = session.connection()
sql_pid = int(connection.execute(text("SELECT pg_backend_pid()")).scalar_one())
driver_pid = self._connection_backend_pid(connection)
self.assertEqual(sql_pid, driver_pid)
return sql_pid
@staticmethod
def _connection_backend_pid(connection: Connection) -> int:
driver_connection = connection.connection.driver_connection
return int(driver_connection.info.backend_pid)
def _assert_failed_retirement_rolled_back(self) -> None:
assert self.engine is not None
with self.engine.connect() as connection:
inspector = inspect(connection)
self.assertTrue(inspector.has_table(self.credential_model.__tablename__))
self.assertTrue(inspector.has_table(self.profile_model.__tablename__))
state = connection.execute(
text(
"SELECT enabled, password_encrypted IS NOT NULL AS has_password, "
"token_encrypted IS NOT NULL AS has_token, username IS NOT NULL AS has_username, "
"secret_ref IS NOT NULL AS has_secret_reference, metadata::jsonb <> '{}'::jsonb AS has_metadata "
"FROM file_connector_credentials WHERE id = :credential_id"
),
{"credential_id": _CREDENTIAL_ID},
).mappings().one()
self.assertTrue(all(state.values()), "credential state was not fully restored")
self.assertEqual(0, self._row_count(connection, "retirement_scrub_probe"))
self.assertEqual(0, self._row_count(connection, "audit_log"))
self.assertEqual(0, self._row_count(connection, "core_change_sequence"))
def _assert_non_secret_audit_details(self, details: dict[str, object]) -> None:
self.assertEqual("module_data_retired", details.get("deletion_reason"))
self.assertEqual("unowned_external_reference_detached", details.get("storage_backend"))
self.assertEqual("<redacted>", details.get("deleted_secret_kinds"))
self.assertEqual(
["password_env", "token_env", "unowned_external_secret_ref"],
details.get("removed_reference_kinds"),
)
self.assertTrue(details.get("removed_metadata"))
serialized = json.dumps(details, sort_keys=True)
self.assertFalse(
any(private_value in serialized for private_value in _PRIVATE_FIXTURE_VALUES),
"audit diagnostics contain fixture-private material",
)
@staticmethod
def _row_count(connection: Connection, table_name: str) -> int:
query = _ROW_COUNT_QUERIES.get(table_name)
if query is None:
raise ValueError("unsupported retirement proof table")
return int(connection.execute(query).scalar_one())
def _cleanup(self) -> None:
if getattr(self, "previous_runtime", None) is None:
clear_runtime()
else:
configure_runtime(self.previous_runtime)
if self.engine is not None:
if self.ddl_listener_registered:
event.remove(self.engine, "before_cursor_execute", self._capture_ddl_connection)
self.engine.dispose()
self.engine = None
if self.admin_engine is not None:
try:
with self.admin_engine.begin() as connection:
connection.execute(DropSchema(self.schema_name, cascade=True, if_exists=True))
finally:
self.admin_engine.dispose()
self.admin_engine = None
if __name__ == "__main__":
unittest.main()

143
tests/test_wheel_runtime.py Normal file
View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import sysconfig
import tempfile
import unittest
class WheelRuntimeTests(unittest.TestCase):
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
access_repository = repository_root.parent / "govoplan-access"
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
temporary_root = Path(directory)
wheel_directory = temporary_root / "wheels"
wheel_directory.mkdir()
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(repository_root),
cwd=temporary_root,
)
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(access_repository),
cwd=temporary_root,
)
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
self.assertEqual(1, len(core_wheels))
self.assertEqual(1, len(access_wheels))
virtual_environment = temporary_root / "venv"
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
venv_python = virtual_environment / "bin" / "python"
self._run(
str(venv_python),
"-m",
"pip",
"install",
"--no-deps",
str(core_wheels[0]),
str(access_wheels[0]),
cwd=temporary_root,
)
database_path = temporary_root / "wheel-runtime.db"
probe = self._run(
str(venv_python),
"-c",
self._probe_script(),
str(database_path),
cwd=temporary_root,
env={
**os.environ,
# Reuse only third-party test dependencies from the parent
# environment. Editable .pth files are not processed from
# PYTHONPATH, so Core itself can only come from the wheel.
"PYTHONPATH": sysconfig.get_path("purelib"),
"GOVOPLAN_CORE_SOURCE_ROOT": "",
"GOVOPLAN_ENABLED_MODULES": "",
},
)
result = json.loads(probe.stdout)
installed_package = Path(result["package_file"])
runtime_root = Path(result["runtime_root"])
self.assertTrue(installed_package.is_relative_to(virtual_environment))
self.assertTrue(runtime_root.is_relative_to(virtual_environment))
self.assertNotEqual(repository_root, runtime_root)
self.assertTrue((runtime_root / "alembic.ini").is_file())
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
self.assertIn("4f2a9c8e7b6d", result["heads"])
self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"])
@staticmethod
def _run(*command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
cwd=cwd,
env=env,
check=False,
capture_output=True,
text=True,
)
if result.returncode:
raise AssertionError(
f"Command failed ({result.returncode}): {' '.join(command)}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
return result
@staticmethod
def _probe_script() -> str:
return """
import json
from pathlib import Path
import sys
from alembic import command
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect
import govoplan_core
from govoplan_core.db.migrations import _repo_root, alembic_config
database_url = f"sqlite:///{Path(sys.argv[1])}"
config = alembic_config(database_url=database_url, enabled_modules=())
command.upgrade(config, "heads")
script = ScriptDirectory.from_config(config)
engine = create_engine(database_url)
try:
with engine.connect() as connection:
tables = sorted(inspect(connection).get_table_names())
finally:
engine.dispose()
print(json.dumps({
"package_file": govoplan_core.__file__,
"runtime_root": str(_repo_root()),
"heads": sorted(script.get_heads()),
"tables": tables,
}))
"""
if __name__ == "__main__":
unittest.main()

View File

@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.12", "version": "0.1.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.12", "version": "0.1.14",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
@@ -46,12 +46,12 @@
}, },
"../../govoplan-access/webui": { "../../govoplan-access/webui": {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.8", "version": "0.1.11",
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -65,9 +65,9 @@
}, },
"../../govoplan-addresses/webui": { "../../govoplan-addresses/webui": {
"name": "@govoplan/addresses-webui", "name": "@govoplan/addresses-webui",
"version": "0.1.8", "version": "0.1.9",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -86,7 +86,7 @@
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -102,7 +102,7 @@
"name": "@govoplan/audit-webui", "name": "@govoplan/audit-webui",
"version": "0.1.8", "version": "0.1.8",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -118,7 +118,7 @@
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.8", "version": "0.1.8",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -135,7 +135,7 @@
}, },
"../../govoplan-campaign/webui": { "../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.8", "version": "0.1.11",
"dependencies": { "dependencies": {
"read-excel-file": "9.2.0" "read-excel-file": "9.2.0"
}, },
@@ -143,7 +143,7 @@
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.12",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -173,9 +173,9 @@
}, },
"../../govoplan-docs/webui": { "../../govoplan-docs/webui": {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.8", "version": "0.1.10",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.10",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -192,9 +192,9 @@
}, },
"../../govoplan-files/webui": { "../../govoplan-files/webui": {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.8", "version": "0.1.9",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -213,7 +213,7 @@
"name": "@govoplan/idm-webui", "name": "@govoplan/idm-webui",
"version": "0.1.8", "version": "0.1.8",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -230,12 +230,12 @@
}, },
"../../govoplan-mail/webui": { "../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.8", "version": "0.1.10",
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -251,7 +251,7 @@
"name": "@govoplan/notifications-webui", "name": "@govoplan/notifications-webui",
"version": "0.1.8", "version": "0.1.8",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -289,7 +289,7 @@
"name": "@govoplan/organizations-webui", "name": "@govoplan/organizations-webui",
"version": "0.1.8", "version": "0.1.8",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -306,9 +306,9 @@
}, },
"../../govoplan-policy/webui": { "../../govoplan-policy/webui": {
"name": "@govoplan/policy-webui", "name": "@govoplan/policy-webui",
"version": "0.1.8", "version": "0.1.9",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -322,9 +322,9 @@
}, },
"../../govoplan-scheduling/webui": { "../../govoplan-scheduling/webui": {
"name": "@govoplan/scheduling-webui", "name": "@govoplan/scheduling-webui",
"version": "0.1.8", "version": "0.1.11",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.11",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -1,18 +1,18 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.12", "version": "0.1.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.12", "version": "0.1.14",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8", "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.11",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8", "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
@@ -787,13 +787,13 @@
} }
}, },
"node_modules/@govoplan/campaign-webui": { "node_modules/@govoplan/campaign-webui": {
"version": "0.1.8", "version": "0.1.11",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#30b43913e84b709124512262cc7a5b5fabbf317a", "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#4774a8025cea3fd0962a23741e6d0241d4062663",
"dependencies": { "dependencies": {
"read-excel-file": "9.2.0" "read-excel-file": "9.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.12",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.12", "version": "0.1.14",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.12", "version": "0.1.14",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -31,7 +31,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.11",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8", "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8", "@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8" "@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"