test_migrations.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. from __future__ import annotations
  2. import hashlib
  3. import os
  4. from io import StringIO
  5. from pathlib import Path
  6. import pytest
  7. from alembic import command
  8. from alembic.config import Config
  9. from alembic.script import ScriptDirectory
  10. from sqlalchemy import create_engine, inspect, text
  11. from sqlalchemy.exc import DBAPIError
  12. from sqlalchemy.ext.asyncio import create_async_engine
  13. def test_alembic_upgrade_and_downgrade(tmp_path: Path, monkeypatch: object) -> None:
  14. root = Path(__file__).parents[1]
  15. database = tmp_path / "migration.db"
  16. url = f"sqlite+aiosqlite:///{database}"
  17. monkeypatch.setenv("SCRIPT_BUILD_WRITE_DATABASE_URL", url) # type: ignore[attr-defined]
  18. config = Config(str(root / "alembic.ini"))
  19. command.upgrade(config, "head")
  20. sync_engine = create_engine(f"sqlite:///{database}")
  21. expected = {
  22. "script_build_mission_binding",
  23. "script_build_input_snapshot",
  24. "script_build_artifact_version",
  25. "script_build_publication",
  26. }
  27. assert expected <= set(inspect(sync_engine).get_table_names())
  28. command.downgrade(config, "base")
  29. assert not (expected & set(inspect(sync_engine).get_table_names()))
  30. sync_engine.dispose()
  31. def test_migration_checksum_manifest() -> None:
  32. root = Path(__file__).parents[1]
  33. manifest = root / "migrations" / "CHECKSUMS.sha256"
  34. lines = [line.split(maxsplit=1) for line in manifest.read_text(encoding="utf-8").splitlines()]
  35. assert lines
  36. for expected, relative in lines:
  37. path = root / relative
  38. assert hashlib.sha256(path.read_bytes()).hexdigest() == expected
  39. def test_revision_identifiers_fit_alembic_mysql_version_column() -> None:
  40. root = Path(__file__).parents[1]
  41. scripts = ScriptDirectory.from_config(Config(str(root / "alembic.ini")))
  42. assert all(len(revision.revision) <= 32 for revision in scripts.walk_revisions())
  43. def test_mysql_offline_sql_compiles_without_credentials(monkeypatch: object) -> None:
  44. root = Path(__file__).parents[1]
  45. output = StringIO()
  46. monkeypatch.setenv( # type: ignore[attr-defined]
  47. "SCRIPT_BUILD_WRITE_DATABASE_URL", "mysql+asyncmy://"
  48. )
  49. config = Config(str(root / "alembic.ini"), output_buffer=output)
  50. command.upgrade(config, "head", sql=True)
  51. sql = output.getvalue()
  52. assert sql.count("CREATE TABLE script_build_") == 5
  53. assert "CREATE TABLE script_build_paragraph" not in sql
  54. assert "CREATE TABLE script_build_element" not in sql
  55. assert "DROP CHECK ck_artifact_phase_one_type" in sql
  56. assert "ADD CONSTRAINT ck_artifact_business_type CHECK" in sql
  57. assert "'root_delivery_manifest'" in sql
  58. assert "CREATE TABLE script_build_http_command" in sql
  59. assert "CONSTRAINT uq_publication_build_type UNIQUE" in sql
  60. assert "owner_epoch BIGINT NOT NULL" in sql
  61. assert "owner_acquired_at DATETIME(6)" in sql
  62. assert sql.count("WITH CASCADED CHECK OPTION") == 4
  63. assert "uq_script_build_record_reson_trace" in sql
  64. def test_phase_three_offline_downgrade_fails_closed(monkeypatch: object) -> None:
  65. root = Path(__file__).parents[1]
  66. output = StringIO()
  67. monkeypatch.setenv( # type: ignore[attr-defined]
  68. "SCRIPT_BUILD_WRITE_DATABASE_URL", "mysql+asyncmy://"
  69. )
  70. config = Config(str(root / "alembic.ini"), output_buffer=output)
  71. with pytest.raises(RuntimeError, match="online safety preflight"):
  72. command.downgrade(
  73. config,
  74. "0003_phase_three_publication:0002_phase_two_artifacts",
  75. sql=True,
  76. )
  77. def test_phase_two_artifacts_make_downgrade_refuse_data_loss(
  78. tmp_path: Path, monkeypatch: object
  79. ) -> None:
  80. root = Path(__file__).parents[1]
  81. database = tmp_path / "migration-refusal.db"
  82. url = f"sqlite+aiosqlite:///{database}"
  83. monkeypatch.setenv("SCRIPT_BUILD_WRITE_DATABASE_URL", url) # type: ignore[attr-defined]
  84. config = Config(str(root / "alembic.ini"))
  85. command.upgrade(config, "head")
  86. sync_engine = create_engine(f"sqlite:///{database}")
  87. with sync_engine.begin() as connection:
  88. connection.execute(
  89. text(
  90. "INSERT INTO script_build_artifact_version "
  91. "(id, script_build_id, task_id, attempt_id, spec_version, artifact_type, "
  92. "legacy_branch_id, canonical_json, canonical_sha256, state, created_at, frozen_at) "
  93. "VALUES (1, 1, 'task', 'attempt', 1, 'paragraph', 1, '{}', :digest, "
  94. "'frozen', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  95. ),
  96. {"digest": "a" * 64},
  97. )
  98. with pytest.raises(RuntimeError, match="cannot downgrade while phase-two artifacts exist"):
  99. command.downgrade(config, "0001_phase_one")
  100. assert inspect(sync_engine).has_table("script_build_artifact_version")
  101. sync_engine.dispose()
  102. @pytest.mark.mysql
  103. @pytest.mark.asyncio
  104. async def test_explicit_mysql_dsn_is_reachable() -> None:
  105. dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_DSN")
  106. if not dsn:
  107. pytest.skip("SCRIPT_BUILD_TEST_MYSQL_DSN is not configured")
  108. engine = create_async_engine(dsn, pool_pre_ping=True)
  109. try:
  110. async with engine.connect() as connection:
  111. assert await connection.scalar(text("SELECT 1")) == 1
  112. finally:
  113. await engine.dispose()
  114. @pytest.mark.mysql
  115. @pytest.mark.asyncio
  116. async def test_mysql_phase_three_schema_semantics() -> None:
  117. dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_DSN")
  118. if not dsn:
  119. pytest.skip("SCRIPT_BUILD_TEST_MYSQL_DSN is not configured")
  120. engine = create_async_engine(dsn, pool_pre_ping=True, isolation_level="READ COMMITTED")
  121. try:
  122. async with engine.connect() as connection:
  123. version = str(await connection.scalar(text("SELECT VERSION()"))).split("-", 1)[0]
  124. assert tuple(int(part) for part in version.split(".")[:3]) >= (8, 0, 16)
  125. assert await connection.scalar(text("SELECT @@transaction_isolation")) == (
  126. "READ-COMMITTED"
  127. )
  128. assert await connection.scalar(text("SELECT version_num FROM alembic_version")) == (
  129. "0003_phase_three_publication"
  130. )
  131. precision = await connection.scalar(
  132. text(
  133. "SELECT DATETIME_PRECISION FROM information_schema.COLUMNS "
  134. "WHERE TABLE_SCHEMA=DATABASE() "
  135. "AND TABLE_NAME='script_build_mission_binding' "
  136. "AND COLUMN_NAME='owner_acquired_at'"
  137. )
  138. )
  139. assert precision == 6
  140. views = set(
  141. (
  142. await connection.execute(
  143. text(
  144. "SELECT TABLE_NAME FROM information_schema.VIEWS "
  145. "WHERE TABLE_SCHEMA=DATABASE()"
  146. )
  147. )
  148. ).scalars()
  149. )
  150. assert {
  151. "script_build_candidate_paragraph_v",
  152. "script_build_candidate_element_v",
  153. "script_build_candidate_paragraph_element_v",
  154. "script_build_runtime_record_v",
  155. } <= views
  156. transaction = await connection.begin_nested()
  157. try:
  158. await connection.execute(
  159. text(
  160. "INSERT INTO script_build_candidate_paragraph_v "
  161. "(script_build_id, branch_id, paragraph_index, level, is_active) "
  162. "VALUES (900000, 1, 1, 1, 1)"
  163. )
  164. )
  165. with pytest.raises(DBAPIError):
  166. await connection.execute(
  167. text(
  168. "INSERT INTO script_build_candidate_paragraph_v "
  169. "(script_build_id, branch_id, paragraph_index, level, is_active) "
  170. "VALUES (900000, 0, 2, 1, 1)"
  171. )
  172. )
  173. await connection.execute(
  174. text(
  175. "INSERT INTO script_build_runtime_record_v "
  176. "(id, execution_id, topic_build_id, topic_id, status, reson_trace_id, "
  177. "is_deleted, is_favorited) VALUES "
  178. "(900000, 1, 1, 1, 'running', 'mysql-gate-runtime', 0, 0)"
  179. )
  180. )
  181. with pytest.raises(DBAPIError):
  182. await connection.execute(
  183. text(
  184. "UPDATE script_build_runtime_record_v SET status='success' "
  185. "WHERE id=900000"
  186. )
  187. )
  188. with pytest.raises(DBAPIError):
  189. await connection.execute(
  190. text(
  191. "INSERT INTO script_build_http_command "
  192. "(principal_scope_sha256, route_family, idempotency_key, "
  193. "request_fingerprint, state, created_at, updated_at) VALUES "
  194. "(:digest, 'gate', 'bad-state', :digest, 'invalid', NOW(6), NOW(6))"
  195. ),
  196. {"digest": "a" * 64},
  197. )
  198. finally:
  199. await transaction.rollback()
  200. finally:
  201. await engine.dispose()
  202. @pytest.mark.mysql
  203. @pytest.mark.asyncio
  204. async def test_mysql_phase_three_least_privilege_accounts() -> None:
  205. read_dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_READ_DSN")
  206. runtime_dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_RUNTIME_DSN")
  207. final_dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_FINAL_DSN")
  208. if not all((read_dsn, runtime_dsn, final_dsn)):
  209. pytest.skip("the three least-privilege MySQL DSNs are not configured")
  210. engines = [
  211. create_async_engine(str(read_dsn), isolation_level="READ COMMITTED"),
  212. create_async_engine(str(runtime_dsn), isolation_level="READ COMMITTED"),
  213. create_async_engine(str(final_dsn), isolation_level="READ COMMITTED"),
  214. ]
  215. try:
  216. async with engines[0].connect() as connection:
  217. with pytest.raises(DBAPIError):
  218. await connection.execute(
  219. text(
  220. "INSERT INTO script_build_http_command "
  221. "(principal_scope_sha256, route_family, idempotency_key, "
  222. "request_fingerprint, state, created_at, updated_at) VALUES "
  223. "(:digest, 'grant-gate', 'read', :digest, 'reserved', NOW(6), NOW(6))"
  224. ),
  225. {"digest": "b" * 64},
  226. )
  227. async with engines[1].connect() as connection:
  228. transaction = await connection.begin()
  229. try:
  230. with pytest.raises(DBAPIError):
  231. await connection.execute(
  232. text(
  233. "INSERT INTO script_build_paragraph "
  234. "(script_build_id, branch_id, paragraph_index, level, is_active) "
  235. "VALUES (930001, 0, 1, 1, 1)"
  236. )
  237. )
  238. await connection.execute(
  239. text(
  240. "INSERT INTO script_build_candidate_paragraph_v "
  241. "(script_build_id, branch_id, paragraph_index, level, is_active) "
  242. "VALUES (930001, 1, 1, 1, 1)"
  243. )
  244. )
  245. await connection.execute(
  246. text(
  247. "INSERT INTO script_build_runtime_record_v "
  248. "(id, execution_id, topic_build_id, topic_id, status, reson_trace_id, "
  249. "is_deleted, is_favorited) VALUES "
  250. "(930001, 1, 1, 1, 'running', 'least-privilege-runtime', 0, 0)"
  251. )
  252. )
  253. with pytest.raises(DBAPIError):
  254. await connection.execute(
  255. text(
  256. "UPDATE script_build_runtime_record_v SET status='success' "
  257. "WHERE id=930001"
  258. )
  259. )
  260. finally:
  261. await transaction.rollback()
  262. async with engines[2].connect() as connection:
  263. transaction = await connection.begin()
  264. try:
  265. await connection.execute(
  266. text(
  267. "INSERT INTO script_build_paragraph "
  268. "(script_build_id, branch_id, paragraph_index, level, is_active) "
  269. "VALUES (930002, 0, 1, 1, 1)"
  270. )
  271. )
  272. with pytest.raises(DBAPIError):
  273. await connection.execute(
  274. text(
  275. "INSERT INTO script_build_http_command "
  276. "(principal_scope_sha256, route_family, idempotency_key, "
  277. "request_fingerprint, state, created_at, updated_at) VALUES "
  278. "(:digest, 'grant-gate', 'final', :digest, 'reserved', NOW(6), NOW(6))"
  279. ),
  280. {"digest": "c" * 64},
  281. )
  282. finally:
  283. await transaction.rollback()
  284. finally:
  285. for engine in engines:
  286. await engine.dispose()