fix(security): repair canonical asset integrity
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m32s
CD Pipeline / build-and-deploy (push) Successful in 7m58s
CD Pipeline / post-deploy-checks (push) Successful in 2m25s
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m32s
CD Pipeline / build-and-deploy (push) Successful in 7m58s
CD Pipeline / post-deploy-checks (push) Successful in 2m25s
This commit is contained in:
@@ -52,7 +52,7 @@ def test_cd_applies_and_independently_verifies_the_bounded_migration() -> None:
|
||||
workflow = CD_WORKFLOW.read_text(encoding="utf-8")
|
||||
step = workflow.split(
|
||||
"- name: Reconcile Asset Inventory Type Constraint", 1
|
||||
)[1].split("# 2026-03-31 ogt: 移除中間通知", 1)[0]
|
||||
)[1].split("- name: Repair Asset Inventory Canonical Integrity", 1)[0]
|
||||
script = step.split("run: |", 1)[1]
|
||||
|
||||
assert "MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }}" in step
|
||||
|
||||
71
apps/api/tests/test_asset_inventory_integrity_migration.py
Normal file
71
apps/api/tests/test_asset_inventory_integrity_migration.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MIGRATION = (
|
||||
ROOT
|
||||
/ "apps"
|
||||
/ "api"
|
||||
/ "migrations"
|
||||
/ "asset_inventory_canonical_integrity_2026-07-14.sql"
|
||||
)
|
||||
CD_WORKFLOW = ROOT / ".gitea" / "workflows" / "cd.yaml"
|
||||
|
||||
|
||||
def test_migration_quarantines_duplicates_without_deleting_history() -> None:
|
||||
sql = MIGRATION.read_text(encoding="utf-8")
|
||||
upper = sql.upper()
|
||||
|
||||
assert "SET lock_timeout = '10s'" in sql
|
||||
assert "SET statement_timeout = '90s'" in sql
|
||||
assert "SET enable_indexscan = 'off'" in sql
|
||||
assert "SET enable_indexonlyscan = 'off'" in sql
|
||||
assert "SET enable_bitmapscan = 'off'" in sql
|
||||
assert "ROW_NUMBER() OVER" in sql
|
||||
assert "repair_row_count > 100" in sql
|
||||
assert "__integrity_quarantine__" in sql
|
||||
assert "lifecycle_state = 'deprecated'" in sql
|
||||
assert "REINDEX INDEX asset_inventory_asset_key_key" in sql
|
||||
assert "HAVING COUNT(*) > 1" in sql
|
||||
assert "ON CONFLICT (asset_key) DO UPDATE" in sql
|
||||
assert "gitea_cd_asset_integrity_reconciler" in sql
|
||||
assert '"work_item_id":"AIA-P0-006"' in sql
|
||||
assert "'data_rows_deleted', 0" in sql
|
||||
assert "DELETE FROM" not in upper
|
||||
assert "DROP TABLE" not in upper
|
||||
assert "TRUNCATE" not in upper
|
||||
|
||||
|
||||
def test_cd_runs_bounded_integrity_repair_and_independent_verifier() -> None:
|
||||
workflow = CD_WORKFLOW.read_text(encoding="utf-8")
|
||||
step = workflow.split(
|
||||
"- name: Repair Asset Inventory Canonical Integrity", 1
|
||||
)[1].split("# 2026-03-31 ogt: 移除中間通知", 1)[0]
|
||||
script = step.split("run: |", 1)[1]
|
||||
|
||||
assert "MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }}" in step
|
||||
assert "DATABASE_URL: ${{ secrets.DATABASE_URL }}" in step
|
||||
assert "${{ secrets." not in script
|
||||
assert "--network bridge" in script
|
||||
assert "--network host" not in script
|
||||
assert "asset_inventory_canonical_integrity_2026-07-14.sql" in script
|
||||
assert "SET enable_indexscan TO off" in script
|
||||
assert "SET enable_indexonlyscan TO off" in script
|
||||
assert "SET enable_bitmapscan TO off" in script
|
||||
assert "asset_inventory_integrity_migration_applied=" in script
|
||||
assert "duplicate_group_count=" in script
|
||||
assert "quarantined_row_count=" in script
|
||||
assert "unique_index_healthy=" in script
|
||||
assert "durable_integrity_receipt_present=" in script
|
||||
assert "canonical_integrity_verified=" in script
|
||||
assert "asset_inventory_canonical_integrity_verifier_failed" in script
|
||||
|
||||
|
||||
def test_cd_embedded_integrity_migration_python_compiles() -> None:
|
||||
workflow = CD_WORKFLOW.read_text(encoding="utf-8")
|
||||
step = workflow.split(
|
||||
"- name: Repair Asset Inventory Canonical Integrity", 1
|
||||
)[1]
|
||||
source = step.split("python - <<'PY'", 1)[1].split(" PY", 1)[0]
|
||||
|
||||
compile(textwrap.dedent(source), "<asset-inventory-integrity-migration>", "exec")
|
||||
@@ -142,6 +142,81 @@ def test_asset_upsert_propagates_database_failure(monkeypatch) -> None:
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_asset_key_integrity_preflight_accepts_unique_inventory(monkeypatch) -> None:
|
||||
requested_projects: list[str | None] = []
|
||||
statements: list[str] = []
|
||||
|
||||
class IntegrityResult:
|
||||
def one(self):
|
||||
return 0, True
|
||||
|
||||
class IntegrityDatabase:
|
||||
async def execute(self, statement, parameters=None):
|
||||
statements.append(str(statement))
|
||||
return IntegrityResult()
|
||||
|
||||
class IntegrityContext:
|
||||
async def __aenter__(self):
|
||||
return IntegrityDatabase()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
def fake_db_context(project_id=None):
|
||||
requested_projects.append(project_id)
|
||||
return IntegrityContext()
|
||||
|
||||
monkeypatch.setattr("src.db.base.get_db_context", fake_db_context)
|
||||
|
||||
asyncio.run(asset_scanner_job._assert_asset_key_integrity())
|
||||
|
||||
assert requested_projects == ["awoooi"]
|
||||
assert any("enable_indexscan" in statement for statement in statements)
|
||||
assert any("enable_indexonlyscan" in statement for statement in statements)
|
||||
assert any("enable_bitmapscan" in statement for statement in statements)
|
||||
assert any("HAVING COUNT(*) > 1" in statement for statement in statements)
|
||||
assert any(
|
||||
"asset_inventory_asset_key_key" in statement for statement in statements
|
||||
)
|
||||
|
||||
|
||||
def test_asset_key_integrity_preflight_fails_closed_on_duplicate_key(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
class IntegrityResult:
|
||||
def one(self):
|
||||
return 1, True
|
||||
|
||||
class IntegrityDatabase:
|
||||
async def execute(self, statement, parameters=None):
|
||||
return IntegrityResult()
|
||||
|
||||
class IntegrityContext:
|
||||
async def __aenter__(self):
|
||||
return IntegrityDatabase()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.db.base.get_db_context",
|
||||
lambda project_id=None: IntegrityContext(),
|
||||
)
|
||||
|
||||
async def exercise() -> None:
|
||||
try:
|
||||
await asset_scanner_job._assert_asset_key_integrity()
|
||||
except RuntimeError as exc:
|
||||
assert str(exc) == (
|
||||
"asset_inventory_key_integrity_failed "
|
||||
"duplicate_groups=1 unique_index_healthy=true"
|
||||
)
|
||||
else:
|
||||
raise AssertionError("duplicate canonical keys must fail closed")
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_deprecate_missing_assets_is_bounded_to_successful_prefixes(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
@@ -230,6 +305,9 @@ def test_scan_terminal_is_failed_when_any_collector_is_incomplete(monkeypatch) -
|
||||
async def fake_collect():
|
||||
return [], [], (), ("prometheus_targets",)
|
||||
|
||||
async def fake_integrity():
|
||||
return None
|
||||
|
||||
async def fake_upsert(*args, **kwargs):
|
||||
return 0, 0
|
||||
|
||||
@@ -246,6 +324,11 @@ def test_scan_terminal_is_failed_when_any_collector_is_incomplete(monkeypatch) -
|
||||
terminal.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(asset_scanner_job, "_start_discovery_run", fake_start)
|
||||
monkeypatch.setattr(
|
||||
asset_scanner_job,
|
||||
"_assert_asset_key_integrity",
|
||||
fake_integrity,
|
||||
)
|
||||
monkeypatch.setattr(asset_scanner_job, "_collect_runtime_assets", fake_collect)
|
||||
monkeypatch.setattr(asset_scanner_job, "_upsert_assets", fake_upsert)
|
||||
monkeypatch.setattr(
|
||||
|
||||
Reference in New Issue
Block a user