| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309 |
- from __future__ import annotations
- import hashlib
- import os
- from io import StringIO
- from pathlib import Path
- import pytest
- from alembic import command
- from alembic.config import Config
- from alembic.script import ScriptDirectory
- from sqlalchemy import create_engine, inspect, text
- from sqlalchemy.exc import DBAPIError
- from sqlalchemy.ext.asyncio import create_async_engine
- def test_alembic_upgrade_and_downgrade(tmp_path: Path, monkeypatch: object) -> None:
- root = Path(__file__).parents[1]
- database = tmp_path / "migration.db"
- url = f"sqlite+aiosqlite:///{database}"
- monkeypatch.setenv("SCRIPT_BUILD_WRITE_DATABASE_URL", url) # type: ignore[attr-defined]
- config = Config(str(root / "alembic.ini"))
- command.upgrade(config, "head")
- sync_engine = create_engine(f"sqlite:///{database}")
- expected = {
- "script_build_mission_binding",
- "script_build_input_snapshot",
- "script_build_artifact_version",
- "script_build_publication",
- }
- assert expected <= set(inspect(sync_engine).get_table_names())
- command.downgrade(config, "base")
- assert not (expected & set(inspect(sync_engine).get_table_names()))
- sync_engine.dispose()
- def test_migration_checksum_manifest() -> None:
- root = Path(__file__).parents[1]
- manifest = root / "migrations" / "CHECKSUMS.sha256"
- lines = [line.split(maxsplit=1) for line in manifest.read_text(encoding="utf-8").splitlines()]
- assert lines
- for expected, relative in lines:
- path = root / relative
- assert hashlib.sha256(path.read_bytes()).hexdigest() == expected
- def test_revision_identifiers_fit_alembic_mysql_version_column() -> None:
- root = Path(__file__).parents[1]
- scripts = ScriptDirectory.from_config(Config(str(root / "alembic.ini")))
- assert all(len(revision.revision) <= 32 for revision in scripts.walk_revisions())
- def test_mysql_offline_sql_compiles_without_credentials(monkeypatch: object) -> None:
- root = Path(__file__).parents[1]
- output = StringIO()
- monkeypatch.setenv( # type: ignore[attr-defined]
- "SCRIPT_BUILD_WRITE_DATABASE_URL", "mysql+asyncmy://"
- )
- config = Config(str(root / "alembic.ini"), output_buffer=output)
- command.upgrade(config, "head", sql=True)
- sql = output.getvalue()
- assert sql.count("CREATE TABLE script_build_") == 5
- assert "CREATE TABLE script_build_paragraph" not in sql
- assert "CREATE TABLE script_build_element" not in sql
- assert "DROP CHECK ck_artifact_phase_one_type" in sql
- assert "ADD CONSTRAINT ck_artifact_business_type CHECK" in sql
- assert "'root_delivery_manifest'" in sql
- assert "CREATE TABLE script_build_http_command" in sql
- assert "CONSTRAINT uq_publication_build_type UNIQUE" in sql
- assert "owner_epoch BIGINT NOT NULL" in sql
- assert "owner_acquired_at DATETIME(6)" in sql
- assert sql.count("WITH CASCADED CHECK OPTION") == 4
- assert "uq_script_build_record_reson_trace" in sql
- def test_phase_three_offline_downgrade_fails_closed(monkeypatch: object) -> None:
- root = Path(__file__).parents[1]
- output = StringIO()
- monkeypatch.setenv( # type: ignore[attr-defined]
- "SCRIPT_BUILD_WRITE_DATABASE_URL", "mysql+asyncmy://"
- )
- config = Config(str(root / "alembic.ini"), output_buffer=output)
- with pytest.raises(RuntimeError, match="online safety preflight"):
- command.downgrade(
- config,
- "0003_phase_three_publication:0002_phase_two_artifacts",
- sql=True,
- )
- def test_phase_two_artifacts_make_downgrade_refuse_data_loss(
- tmp_path: Path, monkeypatch: object
- ) -> None:
- root = Path(__file__).parents[1]
- database = tmp_path / "migration-refusal.db"
- url = f"sqlite+aiosqlite:///{database}"
- monkeypatch.setenv("SCRIPT_BUILD_WRITE_DATABASE_URL", url) # type: ignore[attr-defined]
- config = Config(str(root / "alembic.ini"))
- command.upgrade(config, "head")
- sync_engine = create_engine(f"sqlite:///{database}")
- with sync_engine.begin() as connection:
- connection.execute(
- text(
- "INSERT INTO script_build_artifact_version "
- "(id, script_build_id, task_id, attempt_id, spec_version, artifact_type, "
- "legacy_branch_id, canonical_json, canonical_sha256, state, created_at, frozen_at) "
- "VALUES (1, 1, 'task', 'attempt', 1, 'paragraph', 1, '{}', :digest, "
- "'frozen', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
- ),
- {"digest": "a" * 64},
- )
- with pytest.raises(RuntimeError, match="cannot downgrade while phase-two artifacts exist"):
- command.downgrade(config, "0001_phase_one")
- assert inspect(sync_engine).has_table("script_build_artifact_version")
- sync_engine.dispose()
- @pytest.mark.mysql
- @pytest.mark.asyncio
- async def test_explicit_mysql_dsn_is_reachable() -> None:
- dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_DSN")
- if not dsn:
- pytest.skip("SCRIPT_BUILD_TEST_MYSQL_DSN is not configured")
- engine = create_async_engine(dsn, pool_pre_ping=True)
- try:
- async with engine.connect() as connection:
- assert await connection.scalar(text("SELECT 1")) == 1
- finally:
- await engine.dispose()
- @pytest.mark.mysql
- @pytest.mark.asyncio
- async def test_mysql_phase_three_schema_semantics() -> None:
- dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_DSN")
- if not dsn:
- pytest.skip("SCRIPT_BUILD_TEST_MYSQL_DSN is not configured")
- engine = create_async_engine(dsn, pool_pre_ping=True, isolation_level="READ COMMITTED")
- try:
- async with engine.connect() as connection:
- version = str(await connection.scalar(text("SELECT VERSION()"))).split("-", 1)[0]
- assert tuple(int(part) for part in version.split(".")[:3]) >= (8, 0, 16)
- assert await connection.scalar(text("SELECT @@transaction_isolation")) == (
- "READ-COMMITTED"
- )
- assert await connection.scalar(text("SELECT version_num FROM alembic_version")) == (
- "0003_phase_three_publication"
- )
- precision = await connection.scalar(
- text(
- "SELECT DATETIME_PRECISION FROM information_schema.COLUMNS "
- "WHERE TABLE_SCHEMA=DATABASE() "
- "AND TABLE_NAME='script_build_mission_binding' "
- "AND COLUMN_NAME='owner_acquired_at'"
- )
- )
- assert precision == 6
- views = set(
- (
- await connection.execute(
- text(
- "SELECT TABLE_NAME FROM information_schema.VIEWS "
- "WHERE TABLE_SCHEMA=DATABASE()"
- )
- )
- ).scalars()
- )
- assert {
- "script_build_candidate_paragraph_v",
- "script_build_candidate_element_v",
- "script_build_candidate_paragraph_element_v",
- "script_build_runtime_record_v",
- } <= views
- transaction = await connection.begin_nested()
- try:
- await connection.execute(
- text(
- "INSERT INTO script_build_candidate_paragraph_v "
- "(script_build_id, branch_id, paragraph_index, level, is_active) "
- "VALUES (900000, 1, 1, 1, 1)"
- )
- )
- with pytest.raises(DBAPIError):
- await connection.execute(
- text(
- "INSERT INTO script_build_candidate_paragraph_v "
- "(script_build_id, branch_id, paragraph_index, level, is_active) "
- "VALUES (900000, 0, 2, 1, 1)"
- )
- )
- await connection.execute(
- text(
- "INSERT INTO script_build_runtime_record_v "
- "(id, execution_id, topic_build_id, topic_id, status, reson_trace_id, "
- "is_deleted, is_favorited) VALUES "
- "(900000, 1, 1, 1, 'running', 'mysql-gate-runtime', 0, 0)"
- )
- )
- with pytest.raises(DBAPIError):
- await connection.execute(
- text(
- "UPDATE script_build_runtime_record_v SET status='success' "
- "WHERE id=900000"
- )
- )
- with pytest.raises(DBAPIError):
- await connection.execute(
- text(
- "INSERT INTO script_build_http_command "
- "(principal_scope_sha256, route_family, idempotency_key, "
- "request_fingerprint, state, created_at, updated_at) VALUES "
- "(:digest, 'gate', 'bad-state', :digest, 'invalid', NOW(6), NOW(6))"
- ),
- {"digest": "a" * 64},
- )
- finally:
- await transaction.rollback()
- finally:
- await engine.dispose()
- @pytest.mark.mysql
- @pytest.mark.asyncio
- async def test_mysql_phase_three_least_privilege_accounts() -> None:
- read_dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_READ_DSN")
- runtime_dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_RUNTIME_DSN")
- final_dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_FINAL_DSN")
- if not all((read_dsn, runtime_dsn, final_dsn)):
- pytest.skip("the three least-privilege MySQL DSNs are not configured")
- engines = [
- create_async_engine(str(read_dsn), isolation_level="READ COMMITTED"),
- create_async_engine(str(runtime_dsn), isolation_level="READ COMMITTED"),
- create_async_engine(str(final_dsn), isolation_level="READ COMMITTED"),
- ]
- try:
- async with engines[0].connect() as connection:
- with pytest.raises(DBAPIError):
- await connection.execute(
- text(
- "INSERT INTO script_build_http_command "
- "(principal_scope_sha256, route_family, idempotency_key, "
- "request_fingerprint, state, created_at, updated_at) VALUES "
- "(:digest, 'grant-gate', 'read', :digest, 'reserved', NOW(6), NOW(6))"
- ),
- {"digest": "b" * 64},
- )
- async with engines[1].connect() as connection:
- transaction = await connection.begin()
- try:
- with pytest.raises(DBAPIError):
- await connection.execute(
- text(
- "INSERT INTO script_build_paragraph "
- "(script_build_id, branch_id, paragraph_index, level, is_active) "
- "VALUES (930001, 0, 1, 1, 1)"
- )
- )
- await connection.execute(
- text(
- "INSERT INTO script_build_candidate_paragraph_v "
- "(script_build_id, branch_id, paragraph_index, level, is_active) "
- "VALUES (930001, 1, 1, 1, 1)"
- )
- )
- await connection.execute(
- text(
- "INSERT INTO script_build_runtime_record_v "
- "(id, execution_id, topic_build_id, topic_id, status, reson_trace_id, "
- "is_deleted, is_favorited) VALUES "
- "(930001, 1, 1, 1, 'running', 'least-privilege-runtime', 0, 0)"
- )
- )
- with pytest.raises(DBAPIError):
- await connection.execute(
- text(
- "UPDATE script_build_runtime_record_v SET status='success' "
- "WHERE id=930001"
- )
- )
- finally:
- await transaction.rollback()
- async with engines[2].connect() as connection:
- transaction = await connection.begin()
- try:
- await connection.execute(
- text(
- "INSERT INTO script_build_paragraph "
- "(script_build_id, branch_id, paragraph_index, level, is_active) "
- "VALUES (930002, 0, 1, 1, 1)"
- )
- )
- with pytest.raises(DBAPIError):
- await connection.execute(
- text(
- "INSERT INTO script_build_http_command "
- "(principal_scope_sha256, route_family, idempotency_key, "
- "request_fingerprint, state, created_at, updated_at) VALUES "
- "(:digest, 'grant-gate', 'final', :digest, 'reserved', NOW(6), NOW(6))"
- ),
- {"digest": "c" * 64},
- )
- finally:
- await transaction.rollback()
- finally:
- for engine in engines:
- await engine.dispose()
|