feat(v2): complete v2 reimplementation

This commit is contained in:
2026-07-31 13:33:39 +02:00
parent 396219e776
commit bd107d6a30
137 changed files with 20737 additions and 155 deletions
+199
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib
import os
import sqlite3
import stat
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path
@@ -24,8 +25,13 @@ EXPECTED_TABLES = {
"idempotency_records",
"jobs",
"notification_deliveries",
"notification_delivery_attempts",
"notification_email_settings",
"notification_events",
"notification_signing_keys",
"notification_subscriptions",
"repositories",
"repository_data_key_epochs",
"restores",
"schedules",
"secrets",
@@ -103,6 +109,199 @@ async def test_startup_rejects_unmigrated_database(tmp_path: Path) -> None:
await engine.dispose()
def test_local_only_sources_migration_rejects_existing_remote_sources(
tmp_path: Path,
) -> None:
settings = settings_for(tmp_path)
migration = alembic_config(settings.database_url)
command.upgrade(migration, "0005_restore_dry_run")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("remote", "sftp", "{}", "[]", "active", None, "source-remote"),
)
with pytest.raises(RuntimeError, match="found 1 non-local source row"):
command.upgrade(migration, "head")
with sqlite3.connect(settings.database_path) as connection:
connection.execute("UPDATE sources SET kind = ? WHERE id = ?", ("local", "source-remote"))
command.upgrade(migration, "head")
with (
sqlite3.connect(settings.database_path) as connection,
pytest.raises(sqlite3.IntegrityError),
):
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("remote-two", "sftp", "{}", "[]", "active", None, "source-remote-two"),
)
command.downgrade(migration, "0005_restore_dry_run")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("remote-three", "sftp", "{}", "[]", "active", None, "source-remote-three"),
)
def test_0009_allows_ssh_sources_and_refuses_populated_downgrade(
tmp_path: Path,
) -> None:
settings = settings_for(tmp_path)
migration = alembic_config(settings.database_url)
command.upgrade(migration, "0008_notification_outbox")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("local", "local", '{"root":"/tmp"}', "[]", "active", None, "source-local"),
)
command.upgrade(migration, "head")
with sqlite3.connect(settings.database_path) as connection:
local_source = connection.execute("SELECT kind FROM sources").fetchone()
assert local_source is not None
assert local_source[0] == "local"
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
"ssh",
"ssh",
'{"hostname":"backup.example.test"}',
'["secret"]',
"active",
None,
"source-ssh",
),
)
with pytest.raises(sqlite3.IntegrityError):
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("sftp", "sftp", "{}", "[]", "active", None, "source-sftp"),
)
with pytest.raises(RuntimeError, match="found 1 SSH source row"):
command.downgrade(migration, "0008_notification_outbox")
with sqlite3.connect(settings.database_path) as connection:
connection.execute("DELETE FROM sources WHERE id = ?", ("source-ssh",))
command.downgrade(migration, "0008_notification_outbox")
with (
sqlite3.connect(settings.database_path) as connection,
pytest.raises(sqlite3.IntegrityError),
):
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
"ssh-after-downgrade",
"ssh",
"{}",
"[]",
"active",
None,
"source-ssh-after",
),
)
def test_0008_migrates_unexpected_legacy_notification_delivery(tmp_path: Path) -> None:
settings = settings_for(tmp_path)
migration = alembic_config(settings.database_url)
command.upgrade(migration, "0007_repository_data_key_epochs")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO notification_subscriptions
(id, channel, event_filters, destination_config, state)
VALUES (?, ?, ?, ?, ?)
""",
(
"subscription",
"email",
'["execution.queued"]',
'{"recipients":["a@example.test"]}',
"active",
),
)
connection.execute(
"""
INSERT INTO notification_deliveries
(id, event_id, subscription_id, attempt, state, response_class)
VALUES (?, ?, ?, ?, ?, ?)
""",
("delivery", "legacy-event", "subscription", 1, "delivered", "http_200"),
)
command.upgrade(migration, "head")
with sqlite3.connect(settings.database_path) as connection:
event = connection.execute("SELECT type, payload FROM notification_events").fetchone()
attempt = connection.execute(
"SELECT number, outcome, response_class FROM notification_delivery_attempts"
).fetchone()
assert event is not None and event[0] == "notification.legacy"
assert "legacy-event" in event[1]
assert attempt is not None
assert attempt[0] == 1
assert attempt[1] == "delivered"
assert attempt[2] == "http_200"
def test_0007_downgrade_refuses_populated_key_metadata(tmp_path: Path) -> None:
settings = settings_for(tmp_path)
migration = alembic_config(settings.database_url)
command.upgrade(migration, "head")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO repositories
(id, name, root, format_version, compression, encryption, active_data_key_id, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"repository",
"repository",
str(settings.repository_roots[0]),
1,
"none",
"none",
"epoch",
"active",
),
)
connection.execute(
"""
INSERT INTO repository_data_key_epochs (id, repository_id, key_id, state)
VALUES (?, ?, ?, ?)
""",
("epoch-row", "repository", "epoch", "active"),
)
with pytest.raises(
RuntimeError, match="cannot downgrade while repository data key metadata exists"
):
command.downgrade(migration, "0006_local_sources_only")
def test_database_role_rejects_unmigrated_database(tmp_path: Path) -> None:
cli = importlib.import_module("backup_tool.cli")
db_module = importlib.import_module("backup_tool.db.engine")