feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from backup_tool.db.models import (
|
||||
Backup,
|
||||
Execution,
|
||||
Job,
|
||||
NotificationDelivery,
|
||||
NotificationEvent,
|
||||
NotificationSubscription,
|
||||
Repository,
|
||||
Schedule,
|
||||
Source,
|
||||
)
|
||||
from backup_tool.execution import record_event
|
||||
from backup_tool.notifications.events import EVENT_CATALOG
|
||||
from backup_tool.scheduler import deliver_due
|
||||
from backup_tool.worker import Worker
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_catalog_events_are_produced_and_delivered(app_client) -> None:
|
||||
"""Exercise the execution producer, not emit_event(), for each live execution type."""
|
||||
client, _ = app_client
|
||||
setup = await client.post(
|
||||
"/api/v2/setup",
|
||||
json={"username": "admin", "password": "correct horse battery staple"},
|
||||
)
|
||||
assert setup.status_code == 201
|
||||
app = client._transport.app
|
||||
expected = {
|
||||
"execution.queued",
|
||||
"execution.started",
|
||||
"execution.committed",
|
||||
"execution.failed",
|
||||
"execution.cancelled",
|
||||
"execution.retry_queued",
|
||||
"execution.worker_recovered",
|
||||
}
|
||||
assert expected <= set(EVENT_CATALOG)
|
||||
deferred_prefixes = ["source.", "gc.", "reconciliation."]
|
||||
assert not any(item.startswith(tuple(deferred_prefixes)) for item in EVENT_CATALOG)
|
||||
async with app.state.sessions() as db:
|
||||
repository = Repository(
|
||||
name="events-repository",
|
||||
root="/events-repository",
|
||||
format_version=1,
|
||||
compression="none",
|
||||
encryption="none",
|
||||
)
|
||||
source = Source(
|
||||
name="events-source",
|
||||
kind="local",
|
||||
public_config={"root": "/events-source"},
|
||||
secret_refs=[],
|
||||
)
|
||||
db.add_all([repository, source])
|
||||
await db.flush()
|
||||
job = Job(
|
||||
name="events-job",
|
||||
source_id=source.id,
|
||||
repository_id=repository.id,
|
||||
exclusions=[],
|
||||
retention={},
|
||||
requested_mode="full",
|
||||
)
|
||||
subscription = NotificationSubscription(
|
||||
channel="email",
|
||||
event_filters=["execution.*"],
|
||||
destination_config={"recipients": ["operator@example.test"]},
|
||||
rate_limit_per_minute=60,
|
||||
rate_tokens=60.0,
|
||||
)
|
||||
db.add_all([job, subscription])
|
||||
await db.flush()
|
||||
cases = (
|
||||
("queued", 1, None, "execution.queued"),
|
||||
("preparing", 1, None, "execution.started"),
|
||||
("committed", 1, None, "execution.committed"),
|
||||
("failed", 1, "transient_io", "execution.failed"),
|
||||
("cancelled", 1, "cancellation_requested", "execution.cancelled"),
|
||||
("queued", 2, None, "execution.retry_queued"),
|
||||
("queued", 1, "worker_lost", "execution.worker_recovered"),
|
||||
)
|
||||
for state, attempt, reason, _event_type in cases:
|
||||
execution = Execution(
|
||||
job_id=job.id,
|
||||
trigger="manual",
|
||||
state=state,
|
||||
attempt=attempt,
|
||||
reason_code=reason,
|
||||
progress={},
|
||||
)
|
||||
db.add(execution)
|
||||
await db.flush()
|
||||
await record_event(db, execution)
|
||||
if state in {"queued", "preparing"}:
|
||||
execution.state = "failed"
|
||||
execution.reason_code = "test_cleanup"
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
event_statement = select(NotificationEvent.type).where(NotificationEvent.type.in_(expected))
|
||||
event_types = set((await db.scalars(event_statement)).all())
|
||||
deliveries = await db.scalar(
|
||||
select(NotificationDelivery.id)
|
||||
.join(NotificationEvent, NotificationDelivery.event_id == NotificationEvent.id)
|
||||
.where(NotificationEvent.type.in_(expected))
|
||||
.limit(1)
|
||||
)
|
||||
assert event_types == expected
|
||||
assert deliveries is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_catalog_events_are_produced_and_delivered(app_client) -> None:
|
||||
client, _ = app_client
|
||||
setup = await client.post(
|
||||
"/api/v2/setup",
|
||||
json={"username": "admin", "password": "correct horse battery staple"},
|
||||
)
|
||||
assert setup.status_code == 201
|
||||
csrf = client.cookies["backup_tool_csrf"]
|
||||
app = client._transport.app
|
||||
expected = {
|
||||
"schedule.created",
|
||||
"schedule.updated",
|
||||
"schedule.deleted",
|
||||
"schedule.enabled",
|
||||
"schedule.disabled",
|
||||
"schedule.occurrence_enqueued",
|
||||
"schedule.occurrence_misfired",
|
||||
"schedule.occurrence_blocked",
|
||||
}
|
||||
assert expected <= set(EVENT_CATALOG)
|
||||
async with app.state.sessions() as db:
|
||||
repository = Repository(
|
||||
name="schedule-repository",
|
||||
root="/schedule-repository",
|
||||
format_version=1,
|
||||
compression="none",
|
||||
encryption="none",
|
||||
)
|
||||
source = Source(
|
||||
name="schedule-source",
|
||||
kind="local",
|
||||
public_config={"root": "/schedule-source"},
|
||||
secret_refs=[],
|
||||
)
|
||||
db.add_all([repository, source])
|
||||
await db.flush()
|
||||
jobs = [
|
||||
Job(
|
||||
name=f"schedule-job-{number}",
|
||||
source_id=source.id,
|
||||
repository_id=repository.id,
|
||||
exclusions=[],
|
||||
retention={},
|
||||
requested_mode="full",
|
||||
enabled=number not in {2, 3},
|
||||
)
|
||||
for number in range(1, 5)
|
||||
]
|
||||
subscription = NotificationSubscription(
|
||||
channel="email",
|
||||
event_filters=["schedule.*"],
|
||||
destination_config={"recipients": ["operator@example.test"]},
|
||||
rate_limit_per_minute=60,
|
||||
rate_tokens=60.0,
|
||||
)
|
||||
db.add_all([*jobs, subscription])
|
||||
await db.commit()
|
||||
job_ids = [job.id for job in jobs]
|
||||
|
||||
created = await client.post(
|
||||
f"/api/v2/jobs/{job_ids[0]}/schedule",
|
||||
json={"cron": "0 0 * * *", "timezone": "UTC", "enabled": True},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
disabled = await client.patch(
|
||||
f"/api/v2/jobs/{job_ids[0]}/schedule",
|
||||
json={"cron": "0 0 * * *", "timezone": "UTC", "enabled": False},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert disabled.status_code == 200
|
||||
enabled = await client.patch(
|
||||
f"/api/v2/jobs/{job_ids[0]}/schedule",
|
||||
json={"cron": "1 0 * * *", "timezone": "UTC", "enabled": True},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert enabled.status_code == 200
|
||||
updated = await client.patch(
|
||||
f"/api/v2/jobs/{job_ids[0]}/schedule",
|
||||
json={"cron": "2 0 * * *", "timezone": "UTC", "enabled": True},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert (
|
||||
await client.delete(f"/api/v2/jobs/{job_ids[0]}/schedule", headers={"X-CSRF-Token": csrf})
|
||||
).status_code == 204
|
||||
|
||||
async with app.state.sessions() as db:
|
||||
now = datetime.now(UTC)
|
||||
db.add_all(
|
||||
[
|
||||
Schedule(
|
||||
job_id=job_ids[1],
|
||||
cron="* * * * *",
|
||||
timezone="UTC",
|
||||
misfire_grace_seconds=0,
|
||||
enabled=True,
|
||||
next_nominal_at=now - timedelta(hours=1),
|
||||
),
|
||||
Schedule(
|
||||
job_id=job_ids[2],
|
||||
cron="* * * * *",
|
||||
timezone="UTC",
|
||||
misfire_grace_seconds=60,
|
||||
enabled=True,
|
||||
next_nominal_at=now,
|
||||
),
|
||||
Schedule(
|
||||
job_id=job_ids[3],
|
||||
cron="* * * * *",
|
||||
timezone="UTC",
|
||||
misfire_grace_seconds=60,
|
||||
enabled=True,
|
||||
next_nominal_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
assert await deliver_due(db, now=now) == 1
|
||||
statement = select(NotificationEvent.type).where(NotificationEvent.type.in_(expected))
|
||||
event_types = set((await db.scalars(statement)).all())
|
||||
delivery = await db.scalar(
|
||||
select(NotificationDelivery.id)
|
||||
.join(NotificationEvent, NotificationDelivery.event_id == NotificationEvent.id)
|
||||
.where(NotificationEvent.type.in_(expected))
|
||||
.limit(1)
|
||||
)
|
||||
assert event_types == expected
|
||||
assert delivery is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_restore_and_retention_events_are_produced_and_delivered(
|
||||
app_client,
|
||||
) -> None:
|
||||
client, settings = app_client
|
||||
source_root = settings.local_source_roots[0] / "notification-project"
|
||||
source_root.mkdir()
|
||||
(source_root / "data.txt").write_text("notification data\n", encoding="utf-8")
|
||||
setup = await client.post(
|
||||
"/api/v2/setup",
|
||||
json={"username": "admin", "password": "correct horse battery staple"},
|
||||
)
|
||||
assert setup.status_code == 201
|
||||
headers = {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
|
||||
subscription = await client.post(
|
||||
"/api/v2/notifications/subscriptions",
|
||||
json={
|
||||
"channel": "email",
|
||||
"event_filters": ["backup.*", "restore.*", "retention.*"],
|
||||
"destination": {"recipients": ["operator@example.test"]},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert subscription.status_code == 201
|
||||
repository = await client.post(
|
||||
"/api/v2/repositories",
|
||||
json={"name": "notification-repo", "relative_path": "notification-repo"},
|
||||
headers=headers,
|
||||
)
|
||||
source = await client.post(
|
||||
"/api/v2/sources",
|
||||
json={
|
||||
"name": "notification-source",
|
||||
"kind": "local",
|
||||
"public_config": {"root": str(source_root)},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert repository.status_code == source.status_code == 201
|
||||
job = await client.post(
|
||||
"/api/v2/jobs",
|
||||
json={
|
||||
"name": "notification-job",
|
||||
"source_id": source.json()["id"],
|
||||
"repository_id": repository.json()["id"],
|
||||
"requested_mode": "full",
|
||||
"exclusions": [],
|
||||
"retention": {"keep_last": 1},
|
||||
"allow_empty": False,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert job.status_code == 201
|
||||
execution = await client.post(f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers)
|
||||
assert execution.status_code == 202
|
||||
worker = Worker(settings, owner="notification-backup-worker")
|
||||
try:
|
||||
assert await worker.run_once()
|
||||
finally:
|
||||
await worker.engine.dispose()
|
||||
app = client._transport.app
|
||||
async with app.state.sessions() as db:
|
||||
backup = await db.scalar(
|
||||
select(Backup).where(Backup.execution_id == execution.json()["id"])
|
||||
)
|
||||
assert backup is not None
|
||||
# A synthetic older catalog entry is valid business state; tombstoning is
|
||||
# performed only through the real retention producer below.
|
||||
older_execution = Execution(
|
||||
job_id=job.json()["id"],
|
||||
trigger="manual",
|
||||
state="committed",
|
||||
progress={},
|
||||
)
|
||||
db.add(older_execution)
|
||||
await db.flush()
|
||||
older = Backup(
|
||||
execution_id=older_execution.id,
|
||||
manifest_id="00000000-0000-7000-8000-000000000001",
|
||||
manifest_digest="0" * 64,
|
||||
logical_bytes=0,
|
||||
stored_bytes=0,
|
||||
integrity="verified",
|
||||
created_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
db.add(older)
|
||||
await db.commit()
|
||||
|
||||
retention_worker = Worker(settings, owner="notification-retention-worker")
|
||||
try:
|
||||
# Retention/GC is executed by worker maintenance, not a direct helper call.
|
||||
assert await retention_worker.run_once()
|
||||
finally:
|
||||
await retention_worker.engine.dispose()
|
||||
|
||||
restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(settings.restore_roots[0] / "notification-restore"),
|
||||
"selection": [],
|
||||
"dry_run": True,
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert restore.status_code == 202
|
||||
restore_worker = Worker(settings, owner="notification-restore-worker")
|
||||
try:
|
||||
assert await restore_worker.run_once()
|
||||
finally:
|
||||
await restore_worker.engine.dispose()
|
||||
|
||||
expected = {
|
||||
"backup.committed",
|
||||
"backup.verification_succeeded",
|
||||
"restore.queued",
|
||||
"restore.committed",
|
||||
"retention.tombstoned",
|
||||
}
|
||||
async with app.state.sessions() as db:
|
||||
types = set(
|
||||
(
|
||||
await db.scalars(
|
||||
select(NotificationEvent.type).where(NotificationEvent.type.in_(expected))
|
||||
)
|
||||
).all()
|
||||
)
|
||||
deliveries = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(NotificationDelivery.id)
|
||||
.join(
|
||||
NotificationEvent,
|
||||
NotificationDelivery.event_id == NotificationEvent.id,
|
||||
)
|
||||
.where(NotificationEvent.type.in_(expected))
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert types == expected
|
||||
assert len(deliveries) >= len(expected)
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
snapshot = importlib.import_module("backup_tool.snapshot")
|
||||
|
||||
|
||||
def test_empty_source_requires_explicit_opt_in() -> None:
|
||||
with pytest.raises(snapshot.SnapshotError, match="source_empty"):
|
||||
snapshot.require_nonempty([], False)
|
||||
snapshot.require_nonempty([], True)
|
||||
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from alembic import command
|
||||
from backup_tool.cli import (
|
||||
build_alembic_config,
|
||||
recovery_export_payload,
|
||||
rotate_repository_key,
|
||||
)
|
||||
from backup_tool.cli import main as cli_main
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.engine import create_engine
|
||||
from backup_tool.db.models import Backup, Execution, Repository, RepositoryDataKeyEpoch
|
||||
from backup_tool.repository import (
|
||||
begin_key_rotation,
|
||||
initialize,
|
||||
inspect_repository,
|
||||
replace_active_data_key,
|
||||
)
|
||||
from backup_tool.security.repository_crypto import create_data_key
|
||||
from backup_tool.worker import Worker
|
||||
from sqlalchemy import select
|
||||
|
||||
from .test_repository_safety import settings_for
|
||||
|
||||
PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
async def login(client: httpx.AsyncClient) -> dict[str, str]:
|
||||
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||
assert response.status_code == 201
|
||||
return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
|
||||
|
||||
|
||||
def test_encrypted_initialization_creates_private_data_key(tmp_path: Path) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
|
||||
assert initialized.data_key_id is not None
|
||||
assert initialized.data_key_path is not None
|
||||
assert stat.S_IMODE(initialized.data_key_path.stat().st_mode) == 0o600
|
||||
try:
|
||||
payload = json.loads((initialized.root / "repository.json").read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise AssertionError("encrypted repository metadata is unreadable") from error
|
||||
assert payload["encryption"] == {
|
||||
"mode": "aes-256-gcm",
|
||||
"key_id": initialized.data_key_id,
|
||||
}
|
||||
inspected = inspect_repository(settings, initialized.root)
|
||||
assert inspected.encryption == "aes-256-gcm"
|
||||
assert inspected.data_key_id == initialized.data_key_id
|
||||
|
||||
|
||||
def test_rotation_rejects_metadata_database_epoch_mismatch(tmp_path: Path) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
command.upgrade(build_alembic_config(settings), "head")
|
||||
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
|
||||
assert initialized.data_key_id is not None
|
||||
|
||||
async def create_repository() -> str:
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
repository = Repository(
|
||||
name="encrypted",
|
||||
root=str(initialized.root),
|
||||
format_version=initialized.format_version,
|
||||
compression=initialized.compression,
|
||||
encryption=initialized.encryption,
|
||||
signing_key_id=initialized.signing_key_id,
|
||||
signing_public_key=initialized.signing_public_key,
|
||||
active_data_key_id=initialized.data_key_id,
|
||||
)
|
||||
db.add(repository)
|
||||
await db.flush()
|
||||
db.add(
|
||||
RepositoryDataKeyEpoch(
|
||||
repository_id=repository.id,
|
||||
key_id=initialized.data_key_id,
|
||||
state="active",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return repository.id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
repository_id = asyncio.run(create_repository())
|
||||
replacement_id, replacement_path = create_data_key(settings, initialized.repository_id)
|
||||
try:
|
||||
replace_active_data_key(initialized.root, initialized.data_key_id, replacement_id)
|
||||
with pytest.raises(ValueError, match="repository encryption metadata is invalid"):
|
||||
asyncio.run(rotate_repository_key(settings, repository_id))
|
||||
finally:
|
||||
replacement_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_rotation_reconciliation_clears_stale_rollback_journal_after_key_removal(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
command.upgrade(build_alembic_config(settings), "head")
|
||||
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
|
||||
assert initialized.data_key_id is not None
|
||||
|
||||
async def create_repository() -> str:
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
repository = Repository(
|
||||
name="encrypted",
|
||||
root=str(initialized.root),
|
||||
format_version=initialized.format_version,
|
||||
compression=initialized.compression,
|
||||
encryption=initialized.encryption,
|
||||
signing_key_id=initialized.signing_key_id,
|
||||
signing_public_key=initialized.signing_public_key,
|
||||
active_data_key_id=initialized.data_key_id,
|
||||
)
|
||||
db.add(repository)
|
||||
await db.flush()
|
||||
db.add(
|
||||
RepositoryDataKeyEpoch(
|
||||
repository_id=repository.id,
|
||||
key_id=initialized.data_key_id,
|
||||
state="active",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return repository.id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
repository_id = asyncio.run(create_repository())
|
||||
new_key_id, new_key_path = create_data_key(settings, initialized.repository_id)
|
||||
begin_key_rotation(
|
||||
initialized.root,
|
||||
repository_id,
|
||||
initialized.repository_id,
|
||||
initialized.data_key_id,
|
||||
new_key_id,
|
||||
)
|
||||
new_key_path.unlink()
|
||||
assert (initialized.root / ".key-rotation.json").is_file()
|
||||
|
||||
async def reconcile_and_assert() -> None:
|
||||
worker = Worker(settings, owner="stale-rollback-journal-worker")
|
||||
try:
|
||||
assert await worker.startup() == 1
|
||||
finally:
|
||||
await worker.engine.dispose()
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
repository = await db.get(Repository, repository_id)
|
||||
assert repository is not None
|
||||
assert repository.active_data_key_id == initialized.data_key_id
|
||||
inspected = inspect_repository(settings, initialized.root)
|
||||
assert inspected.data_key_id == initialized.data_key_id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(reconcile_and_assert())
|
||||
assert not (initialized.root / ".key-rotation.json").exists()
|
||||
|
||||
|
||||
def test_rotation_crash_after_db_commit_recovers_on_worker_startup(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
command.upgrade(build_alembic_config(settings), "head")
|
||||
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
|
||||
assert initialized.data_key_id is not None
|
||||
|
||||
async def create_repository() -> str:
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
repository = Repository(
|
||||
name="encrypted",
|
||||
root=str(initialized.root),
|
||||
format_version=initialized.format_version,
|
||||
compression=initialized.compression,
|
||||
encryption=initialized.encryption,
|
||||
signing_key_id=initialized.signing_key_id,
|
||||
signing_public_key=initialized.signing_public_key,
|
||||
active_data_key_id=initialized.data_key_id,
|
||||
)
|
||||
db.add(repository)
|
||||
await db.flush()
|
||||
db.add(
|
||||
RepositoryDataKeyEpoch(
|
||||
repository_id=repository.id,
|
||||
key_id=initialized.data_key_id,
|
||||
state="active",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return repository.id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
repository_id = asyncio.run(create_repository())
|
||||
|
||||
def interrupted_after_database_commit() -> None:
|
||||
raise OSError("simulated process loss after database commit")
|
||||
|
||||
with pytest.raises(OSError, match="simulated process loss"):
|
||||
asyncio.run(
|
||||
rotate_repository_key(
|
||||
settings,
|
||||
repository_id,
|
||||
after_database_commit=interrupted_after_database_commit,
|
||||
)
|
||||
)
|
||||
assert inspect_repository(settings, initialized.root).data_key_id == initialized.data_key_id
|
||||
assert (initialized.root / ".key-rotation.json").is_file()
|
||||
|
||||
async def reconcile_and_assert() -> None:
|
||||
worker = Worker(settings, owner="rotation-recovery-worker")
|
||||
try:
|
||||
assert await worker.startup() == 1
|
||||
finally:
|
||||
await worker.engine.dispose()
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
repository = await db.get(Repository, repository_id)
|
||||
assert repository is not None
|
||||
assert repository.active_data_key_id is not None
|
||||
inspected = inspect_repository(settings, initialized.root)
|
||||
assert inspected.data_key_id == repository.active_data_key_id
|
||||
active_data_key_id = repository.active_data_key_id
|
||||
exported = await recovery_export_payload(settings)
|
||||
exported_repository = exported["catalog"]["repositories"][0]
|
||||
assert exported_repository["active_data_key_id"] == active_data_key_id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(reconcile_and_assert())
|
||||
assert not (initialized.root / ".key-rotation.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encrypted_repository_worker_backup_and_restore(
|
||||
app_client: tuple[httpx.AsyncClient, Settings],
|
||||
) -> None:
|
||||
client, settings = app_client
|
||||
source_root = settings.local_source_roots[0] / "project"
|
||||
source_root.mkdir()
|
||||
plaintext = b"encrypted backup content\n"
|
||||
(source_root / "hello.txt").write_bytes(plaintext)
|
||||
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
|
||||
assert initialized.data_key_id is not None
|
||||
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
repository = Repository(
|
||||
name="encrypted",
|
||||
root=str(initialized.root),
|
||||
format_version=initialized.format_version,
|
||||
compression=initialized.compression,
|
||||
encryption=initialized.encryption,
|
||||
signing_key_id=initialized.signing_key_id,
|
||||
signing_public_key=initialized.signing_public_key,
|
||||
active_data_key_id=initialized.data_key_id,
|
||||
)
|
||||
db.add(repository)
|
||||
await db.flush()
|
||||
db.add(
|
||||
RepositoryDataKeyEpoch(
|
||||
repository_id=repository.id,
|
||||
key_id=initialized.data_key_id,
|
||||
state="active",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
repository_id = repository.id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
headers = await login(client)
|
||||
source_response = await client.post(
|
||||
"/api/v2/sources",
|
||||
json={
|
||||
"name": "local",
|
||||
"kind": "local",
|
||||
"public_config": {"root": str(source_root)},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert source_response.status_code == 201
|
||||
job_response = await client.post(
|
||||
"/api/v2/jobs",
|
||||
json={
|
||||
"name": "encrypted-backup",
|
||||
"source_id": source_response.json()["id"],
|
||||
"repository_id": repository_id,
|
||||
"requested_mode": "full",
|
||||
"exclusions": [],
|
||||
"retention": {},
|
||||
"enabled": True,
|
||||
"allow_empty": False,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert job_response.status_code == 201
|
||||
execution_response = await client.post(
|
||||
f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers
|
||||
)
|
||||
assert execution_response.status_code == 202
|
||||
execution_id = execution_response.json()["id"]
|
||||
|
||||
worker = Worker(settings, owner="encrypted-backup-worker")
|
||||
try:
|
||||
assert await worker.run_once()
|
||||
finally:
|
||||
await worker.engine.dispose()
|
||||
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
execution = await db.get(Execution, execution_id)
|
||||
backup = await db.scalar(select(Backup).where(Backup.execution_id == execution_id))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
assert execution is not None
|
||||
assert execution.state == "committed"
|
||||
assert backup is not None
|
||||
|
||||
blob = next((initialized.root / "blobs" / "sha256").iterdir())
|
||||
assert blob.read_bytes().startswith(b"BTENC\x01")
|
||||
assert plaintext not in blob.read_bytes()
|
||||
manifest_path = initialized.root / "manifests" / f"{backup.manifest_id}.json"
|
||||
stored_manifest = manifest_path.read_bytes()
|
||||
assert stored_manifest.startswith(b"BTENC\x01")
|
||||
assert b'"entries"' not in stored_manifest
|
||||
assert b"hello.txt" not in stored_manifest
|
||||
|
||||
assert (
|
||||
await asyncio.to_thread(
|
||||
cli_main,
|
||||
["admin", "repository-key", "rotate", "--repository-id", repository_id],
|
||||
settings=settings,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
repository = await db.get(Repository, repository_id)
|
||||
epochs = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(RepositoryDataKeyEpoch).where(
|
||||
RepositoryDataKeyEpoch.repository_id == repository_id
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
assert repository is not None
|
||||
assert repository.active_data_key_id != initialized.data_key_id
|
||||
epoch_states = {f"{epoch.key_id}:{epoch.state}" for epoch in epochs}
|
||||
expected_epoch_states = {
|
||||
f"{initialized.data_key_id}:retired",
|
||||
f"{repository.active_data_key_id}:active",
|
||||
}
|
||||
assert epoch_states == expected_epoch_states
|
||||
assert (
|
||||
inspect_repository(settings, initialized.root).data_key_id == repository.active_data_key_id
|
||||
)
|
||||
|
||||
plaintext_after_rotation = b"encrypted content after rotation\n"
|
||||
(source_root / "hello.txt").write_bytes(plaintext_after_rotation)
|
||||
second_execution_response = await client.post(
|
||||
f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers
|
||||
)
|
||||
assert second_execution_response.status_code == 202
|
||||
second_execution_id = second_execution_response.json()["id"]
|
||||
second_worker = Worker(settings, owner="encrypted-rotated-backup-worker")
|
||||
try:
|
||||
assert await second_worker.run_once()
|
||||
finally:
|
||||
await second_worker.engine.dispose()
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
second_backup = await db.scalar(
|
||||
select(Backup).where(Backup.execution_id == second_execution_id)
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
assert second_backup is not None
|
||||
assert second_backup.data_key_id == repository.active_data_key_id
|
||||
|
||||
destination = settings.restore_roots[0] / "restored"
|
||||
restore_response = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert restore_response.status_code == 202
|
||||
source_root.rename(settings.data_dir / "removed-source")
|
||||
restore_worker = Worker(settings, owner="encrypted-restore-worker")
|
||||
try:
|
||||
assert await restore_worker.run_once()
|
||||
finally:
|
||||
await restore_worker.engine.dispose()
|
||||
|
||||
restored = await client.get(
|
||||
f"/api/v2/restores/{restore_response.json()['id']}", headers=headers
|
||||
)
|
||||
assert restored.json()["state"] == "committed"
|
||||
assert (destination / "hello.txt").read_bytes() == plaintext
|
||||
|
||||
rotated_destination = settings.restore_roots[0] / "rotated-restored"
|
||||
rotated_restore_response = await client.post(
|
||||
f"/api/v2/backups/{second_backup.id}/restores",
|
||||
json={
|
||||
"destination": str(rotated_destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert rotated_restore_response.status_code == 202
|
||||
rotated_restore_worker = Worker(settings, owner="encrypted-rotated-restore-worker")
|
||||
try:
|
||||
assert await rotated_restore_worker.run_once()
|
||||
finally:
|
||||
await rotated_restore_worker.engine.dispose()
|
||||
assert (rotated_destination / "hello.txt").read_bytes() == plaintext_after_rotation
|
||||
|
||||
manifest_path.write_bytes(stored_manifest[:-1] + bytes([stored_manifest[-1] ^ 1]))
|
||||
corrupt_destination = settings.restore_roots[0] / "corrupt-manifest"
|
||||
corrupt_restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(corrupt_destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert corrupt_restore.status_code == 202
|
||||
corrupt_worker = Worker(settings, owner="encrypted-corrupt-manifest-worker")
|
||||
try:
|
||||
assert await corrupt_worker.run_once()
|
||||
finally:
|
||||
await corrupt_worker.engine.dispose()
|
||||
|
||||
corrupt_status = await client.get(
|
||||
f"/api/v2/restores/{corrupt_restore.json()['id']}", headers=headers
|
||||
)
|
||||
assert corrupt_status.json()["state"] == "failed"
|
||||
assert not corrupt_destination.exists()
|
||||
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.engine import create_engine
|
||||
from backup_tool.db.models import Backup, Execution, Repository, Restore
|
||||
from backup_tool.snapshot import verify_published_snapshot
|
||||
from backup_tool.worker import Worker
|
||||
from sqlalchemy import select
|
||||
|
||||
PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
async def login(client: httpx.AsyncClient) -> dict[str, str]:
|
||||
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||
assert response.status_code == 201
|
||||
return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_publishes_a_verified_signed_full_backup_and_atomic_restore(
|
||||
app_client: tuple[httpx.AsyncClient, Settings],
|
||||
) -> None:
|
||||
client, settings = app_client
|
||||
source_root = settings.local_source_roots[0] / "project"
|
||||
source_root.mkdir()
|
||||
(source_root / "nested").mkdir()
|
||||
(source_root / "nested" / "hello.txt").write_text("hello backup\n", encoding="utf-8")
|
||||
headers = await login(client)
|
||||
repository_response = await client.post(
|
||||
"/api/v2/repositories",
|
||||
json={
|
||||
"name": "primary",
|
||||
"relative_path": "primary",
|
||||
"compression": "none",
|
||||
"encryption": "none",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert repository_response.status_code == 201
|
||||
source_response = await client.post(
|
||||
"/api/v2/sources",
|
||||
json={
|
||||
"name": "local",
|
||||
"kind": "local",
|
||||
"public_config": {"root": str(source_root)},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert source_response.status_code == 201
|
||||
job_response = await client.post(
|
||||
"/api/v2/jobs",
|
||||
json={
|
||||
"name": "full-backup",
|
||||
"source_id": source_response.json()["id"],
|
||||
"repository_id": repository_response.json()["id"],
|
||||
"requested_mode": "full",
|
||||
"exclusions": [],
|
||||
"retention": {},
|
||||
"enabled": True,
|
||||
"allow_empty": False,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert job_response.status_code == 201
|
||||
execution_response = await client.post(
|
||||
f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers
|
||||
)
|
||||
assert execution_response.status_code == 202
|
||||
execution_id = execution_response.json()["id"]
|
||||
|
||||
worker = Worker(settings, owner="snapshot-worker")
|
||||
try:
|
||||
assert await worker.run_once()
|
||||
finally:
|
||||
await worker.engine.dispose()
|
||||
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
execution = await db.get(Execution, execution_id)
|
||||
backup = await db.scalar(select(Backup).where(Backup.execution_id == execution_id))
|
||||
repository = await db.get(Repository, repository_response.json()["id"])
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
assert execution is not None
|
||||
assert execution.state == "committed"
|
||||
assert backup is not None
|
||||
assert backup.integrity == "verified"
|
||||
assert repository is not None
|
||||
root = Path(repository.root)
|
||||
manifest_path = root / "manifests" / f"{backup.manifest_id}.json"
|
||||
manifest = verify_published_snapshot(root, manifest_path, repository.signing_public_key)
|
||||
file_entry = next(entry for entry in manifest["entries"] if entry["type"] == "file")
|
||||
assert file_entry["path"] == "nested/hello.txt"
|
||||
assert (root / "blobs" / "sha256" / file_entry["blob_digest"]).read_text() == "hello backup\n"
|
||||
|
||||
dry_run_destination = settings.restore_roots[0] / "dry-run-backup"
|
||||
dry_run_response = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(dry_run_destination),
|
||||
"selection": ["nested"],
|
||||
"dry_run": True,
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert dry_run_response.status_code == 202
|
||||
dry_run_worker = Worker(settings, owner="dry-run-worker")
|
||||
try:
|
||||
assert await dry_run_worker.run_once()
|
||||
finally:
|
||||
await dry_run_worker.engine.dispose()
|
||||
dry_run = await client.get(f"/api/v2/restores/{dry_run_response.json()['id']}", headers=headers)
|
||||
assert dry_run.json()["state"] == "committed"
|
||||
assert dry_run.json()["result"]["dry_run"]
|
||||
assert dry_run.json()["result"]["entry_count"] == 2
|
||||
assert not dry_run_destination.exists()
|
||||
|
||||
destination = settings.restore_roots[0] / "restored-backup"
|
||||
restore_response = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert restore_response.status_code == 202
|
||||
restore_id = restore_response.json()["id"]
|
||||
assert restore_response.json()["state"] == "queued"
|
||||
source_root.rename(settings.data_dir / "removed-source")
|
||||
|
||||
restore_worker = Worker(settings, owner="restore-worker")
|
||||
try:
|
||||
assert await restore_worker.run_once()
|
||||
finally:
|
||||
await restore_worker.engine.dispose()
|
||||
|
||||
restored = await client.get(f"/api/v2/restores/{restore_id}", headers=headers)
|
||||
assert restored.status_code == 200
|
||||
assert restored.json()["state"] == "committed"
|
||||
assert restored.json()["result"]["manifest_digest"] == backup.manifest_digest
|
||||
assert (destination / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello backup\n"
|
||||
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
interrupted_restore = await db.get(Restore, restore_id)
|
||||
assert interrupted_restore is not None
|
||||
interrupted_restore.state = "running"
|
||||
interrupted_restore.result = None
|
||||
await db.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
recovery_worker = Worker(settings, owner="recovery-worker")
|
||||
try:
|
||||
assert await recovery_worker.startup() == 1
|
||||
finally:
|
||||
await recovery_worker.engine.dispose()
|
||||
recovered = await client.get(f"/api/v2/restores/{restore_id}", headers=headers)
|
||||
assert recovered.json()["state"] == "committed"
|
||||
|
||||
skipped_restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "skip",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert skipped_restore.status_code == 202
|
||||
skip_worker = Worker(settings, owner="skip-worker")
|
||||
try:
|
||||
assert await skip_worker.run_once()
|
||||
finally:
|
||||
await skip_worker.engine.dispose()
|
||||
skipped = await client.get(f"/api/v2/restores/{skipped_restore.json()['id']}", headers=headers)
|
||||
assert skipped.json()["result"]["skipped"]
|
||||
|
||||
(destination / "nested" / "hello.txt").write_text("replaced", encoding="utf-8")
|
||||
replaced_restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "replace",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert replaced_restore.status_code == 202
|
||||
replace_worker = Worker(settings, owner="replace-worker")
|
||||
try:
|
||||
assert await replace_worker.run_once()
|
||||
finally:
|
||||
await replace_worker.engine.dispose()
|
||||
assert (destination / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello backup\n"
|
||||
|
||||
(root / "blobs" / "sha256" / file_entry["blob_digest"]).write_text("tampered")
|
||||
corrupt_destination = settings.restore_roots[0] / "corrupt-restore"
|
||||
corrupt_restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(corrupt_destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert corrupt_restore.status_code == 202
|
||||
|
||||
corrupt_worker = Worker(settings, owner="corrupt-restore-worker")
|
||||
try:
|
||||
assert await corrupt_worker.run_once()
|
||||
finally:
|
||||
await corrupt_worker.engine.dispose()
|
||||
|
||||
corrupt_status = await client.get(
|
||||
f"/api/v2/restores/{corrupt_restore.json()['id']}", headers=headers
|
||||
)
|
||||
assert corrupt_status.json()["state"] == "failed"
|
||||
assert not corrupt_destination.exists()
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
corrupted_backup = await db.get(Backup, backup.id)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
assert corrupted_backup is not None
|
||||
assert corrupted_backup.integrity == "corrupt"
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from backup_tool.gc import purge_repository
|
||||
from backup_tool.security.repository_crypto import encrypt_object, object_aad
|
||||
|
||||
|
||||
def write_manifest(path: Path, digests: list[str]) -> None:
|
||||
path.write_text(
|
||||
json.dumps({"entries": [{"blob_digest": digest} for digest in digests]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def age(path: Path, days: int = 8) -> None:
|
||||
stamp = (datetime.now(UTC) - timedelta(days=days)).timestamp()
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
def test_gc_purges_tombstoned_manifest_and_only_unreferenced_old_blob(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "repository"
|
||||
manifests = root / "manifests"
|
||||
blobs = root / "blobs" / "sha256"
|
||||
manifests.mkdir(parents=True)
|
||||
blobs.mkdir(parents=True)
|
||||
kept = "a" * 64
|
||||
removed = "b" * 64
|
||||
write_manifest(manifests / "kept.json", [kept])
|
||||
write_manifest(manifests / "deleted.json", [removed])
|
||||
(blobs / kept).write_bytes(b"kept")
|
||||
(blobs / removed).write_bytes(b"removed")
|
||||
age(manifests / "deleted.json")
|
||||
age(blobs / removed)
|
||||
|
||||
report = purge_repository(root, {"deleted"})
|
||||
|
||||
assert report.purged_manifests == 1
|
||||
assert report.purged_blobs == 1
|
||||
assert (manifests / "kept.json").exists()
|
||||
assert (blobs / kept).exists()
|
||||
assert not (blobs / removed).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encrypted", [False, True], ids=["corrupt", "encrypted"])
|
||||
def test_gc_fails_closed_for_unreadable_manifest(tmp_path: Path, encrypted: bool) -> None:
|
||||
root = tmp_path / "repository"
|
||||
manifests = root / "manifests"
|
||||
blobs = root / "blobs" / "sha256"
|
||||
manifests.mkdir(parents=True)
|
||||
blobs.mkdir(parents=True)
|
||||
kept = "a" * 64
|
||||
deleted = "b" * 64
|
||||
write_manifest(manifests / "kept.json", [kept])
|
||||
write_manifest(manifests / "deleted.json", [deleted])
|
||||
unreadable = manifests / "unreadable.json"
|
||||
unreadable.write_bytes(
|
||||
encrypt_object(
|
||||
b"k" * 32,
|
||||
object_aad("repository", "key", "manifest", "unreadable"),
|
||||
b'{"entries": []}',
|
||||
)
|
||||
if encrypted
|
||||
else b"not json"
|
||||
)
|
||||
(blobs / kept).write_bytes(b"kept")
|
||||
(blobs / deleted).write_bytes(b"deleted")
|
||||
age(manifests / "deleted.json")
|
||||
age(blobs / deleted)
|
||||
|
||||
report = purge_repository(root, {"deleted"})
|
||||
|
||||
assert report.purged_manifests == 0
|
||||
assert report.purged_blobs == 0
|
||||
assert (manifests / "deleted.json").exists()
|
||||
assert (blobs / deleted).exists()
|
||||
|
||||
|
||||
def test_gc_purges_encrypted_manifests_with_known_epoch_keys(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repository"
|
||||
manifests = root / "manifests"
|
||||
blobs = root / "blobs" / "sha256"
|
||||
manifests.mkdir(parents=True)
|
||||
blobs.mkdir(parents=True)
|
||||
key = b"k" * 32
|
||||
repository_id = "repository"
|
||||
key_id = "epoch"
|
||||
kept = "a" * 64
|
||||
deleted = "b" * 64
|
||||
for manifest_id, digests in (("kept", [kept]), ("deleted", [deleted])):
|
||||
plaintext = json.dumps(
|
||||
{"entries": [{"blob_digest": digest} for digest in digests]}
|
||||
).encode()
|
||||
(manifests / f"{manifest_id}.json").write_bytes(
|
||||
encrypt_object(
|
||||
key,
|
||||
object_aad(repository_id, key_id, "manifest", manifest_id),
|
||||
plaintext,
|
||||
)
|
||||
)
|
||||
(blobs / kept).write_bytes(b"kept")
|
||||
(blobs / deleted).write_bytes(b"deleted")
|
||||
age(manifests / "deleted.json")
|
||||
age(blobs / deleted)
|
||||
|
||||
report = purge_repository(
|
||||
root,
|
||||
{"deleted"},
|
||||
repository_id=repository_id,
|
||||
manifest_keys={"kept": (key_id, key), "deleted": (key_id, key)},
|
||||
)
|
||||
|
||||
assert report.purged_manifests == 1
|
||||
assert report.purged_blobs == 1
|
||||
assert (blobs / kept).exists()
|
||||
assert not (blobs / deleted).exists()
|
||||
|
||||
|
||||
def test_gc_quarantines_unknown_blob_name(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repository"
|
||||
blobs = root / "blobs" / "sha256"
|
||||
blobs.mkdir(parents=True)
|
||||
unknown = blobs / "not-a-digest"
|
||||
unknown.write_bytes(b"unknown")
|
||||
|
||||
report = purge_repository(root, set())
|
||||
|
||||
assert report.quarantined == 1
|
||||
assert not unknown.exists()
|
||||
assert (root / "quarantine" / "not-a-digest").exists()
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from backup_tool.snapshot import _unsigned_manifest
|
||||
|
||||
|
||||
class Object:
|
||||
def __init__(self, **values: object) -> None:
|
||||
self.__dict__.update(values)
|
||||
|
||||
|
||||
def test_incremental_request_without_baseline_emits_complete_full_manifest() -> None:
|
||||
manifest = _unsigned_manifest(
|
||||
"0198c57f-0000-7000-8000-000000000006",
|
||||
"0198c57f-0000-7000-8000-000000000001",
|
||||
Object(id="0198c57f-0000-7000-8000-000000000003", kind="local"),
|
||||
Object(
|
||||
id="0198c57f-0000-7000-8000-000000000004",
|
||||
requested_mode="incremental",
|
||||
exclusions=[],
|
||||
),
|
||||
Object(id="0198c57f-0000-7000-8000-000000000005"),
|
||||
[],
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
assert manifest["requested_mode"] == "incremental"
|
||||
assert manifest["effective_mode"] == "full"
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from backup_tool.db.models import (
|
||||
NotificationDelivery,
|
||||
NotificationEmailSettings,
|
||||
NotificationEvent,
|
||||
)
|
||||
from backup_tool.ids import new_uuid7
|
||||
from backup_tool.notifications.email import SMTPClient, deliver_email
|
||||
from backup_tool.notifications.events import emit_event
|
||||
from sqlalchemy import select
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
|
||||
|
||||
async def _setup(client) -> str:
|
||||
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||
assert response.status_code == 201
|
||||
return client.cookies["backup_tool_csrf"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filters_manual_test_retry_and_history(app_client) -> None:
|
||||
client, _ = app_client
|
||||
csrf = await _setup(client)
|
||||
created = await client.post(
|
||||
"/api/v2/notifications/subscriptions",
|
||||
json={
|
||||
"channel": "email",
|
||||
"event_filters": ["schedule.*", "notification.test_requested"],
|
||||
"destination": {"recipients": ["operator@example.test"]},
|
||||
},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
subscription_id = created.json()["id"]
|
||||
app = client._transport.app
|
||||
async with app.state.sessions() as db:
|
||||
await emit_event(
|
||||
db,
|
||||
"execution.queued",
|
||||
correlation_id=str(new_uuid7()),
|
||||
resource={},
|
||||
deduplication_key="filtered-out",
|
||||
)
|
||||
scheduled = await emit_event(
|
||||
db,
|
||||
"schedule.created",
|
||||
correlation_id=str(new_uuid7()),
|
||||
resource={},
|
||||
deduplication_key="filtered-in",
|
||||
)
|
||||
await db.commit()
|
||||
statement = select(NotificationDelivery).where(
|
||||
NotificationDelivery.event_id == scheduled.id
|
||||
)
|
||||
deliveries = list((await db.scalars(statement)).all())
|
||||
assert len(deliveries) == 1
|
||||
delivery = deliveries[0]
|
||||
delivery.state = "failed"
|
||||
delivery.terminal_reason = "http_permanent"
|
||||
await db.commit()
|
||||
delivery_id = delivery.id
|
||||
|
||||
tested = await client.post(
|
||||
f"/api/v2/notifications/subscriptions/{subscription_id}/test",
|
||||
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "test-one"},
|
||||
)
|
||||
assert tested.status_code == 202
|
||||
retried = await client.post(
|
||||
f"/api/v2/notifications/deliveries/{delivery_id}/retry",
|
||||
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "retry-one"},
|
||||
)
|
||||
assert retried.status_code == 202
|
||||
replayed = await client.post(
|
||||
f"/api/v2/notifications/deliveries/{delivery_id}/retry",
|
||||
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "retry-one"},
|
||||
)
|
||||
assert replayed.status_code == 202
|
||||
assert replayed.json() == retried.json()
|
||||
history = await client.get("/api/v2/notifications/deliveries")
|
||||
assert history.status_code == 200
|
||||
row = next(item for item in history.json()["items"] if item["id"] == delivery_id)
|
||||
assert row["state"] == "retry"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_test_bypasses_filters_and_targets_only_selected_subscription(
|
||||
app_client,
|
||||
) -> None:
|
||||
client, _ = app_client
|
||||
csrf = await _setup(client)
|
||||
selected = await client.post(
|
||||
"/api/v2/notifications/subscriptions",
|
||||
json={
|
||||
"channel": "email",
|
||||
"event_filters": ["execution.failed"],
|
||||
"destination": {"recipients": ["selected@example.test"]},
|
||||
},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
other = await client.post(
|
||||
"/api/v2/notifications/subscriptions",
|
||||
json={
|
||||
"channel": "email",
|
||||
"event_filters": ["notification.test_requested"],
|
||||
"destination": {"recipients": ["other@example.test"]},
|
||||
},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert selected.status_code == other.status_code == 201
|
||||
response = await client.post(
|
||||
f"/api/v2/notifications/subscriptions/{selected.json()['id']}/test",
|
||||
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "selected-test"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
app = client._transport.app
|
||||
async with app.state.sessions() as db:
|
||||
rows = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(NotificationDelivery.subscription_id).where(
|
||||
NotificationDelivery.event_id == response.json()["event_id"]
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert rows == [selected.json()["id"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_subscription_does_not_receive_future_events(app_client) -> None:
|
||||
client, _ = app_client
|
||||
csrf = await _setup(client)
|
||||
created = await client.post(
|
||||
"/api/v2/notifications/subscriptions",
|
||||
json={
|
||||
"channel": "email",
|
||||
"event_filters": ["execution.*"],
|
||||
"destination": {"recipients": ["operator@example.test"]},
|
||||
},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
subscription = created.json()
|
||||
disabled = await client.patch(
|
||||
f"/api/v2/notifications/subscriptions/{subscription['id']}",
|
||||
json={"state": "disabled"},
|
||||
headers={"X-CSRF-Token": csrf, "If-Match": created.headers["ETag"]},
|
||||
)
|
||||
assert disabled.status_code == 200
|
||||
app = client._transport.app
|
||||
async with app.state.sessions() as db:
|
||||
event = await emit_event(
|
||||
db,
|
||||
"execution.queued",
|
||||
correlation_id=str(new_uuid7()),
|
||||
resource={},
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
await db.commit()
|
||||
assert (
|
||||
await db.scalar(
|
||||
select(NotificationDelivery.id).where(NotificationDelivery.event_id == event.id)
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_uses_ehlo_starttls_then_auth_with_hermetic_fake() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class FakeSMTP:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
calls.append("connect")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args) -> None:
|
||||
calls.append("close")
|
||||
|
||||
def ehlo(self) -> None:
|
||||
calls.append("ehlo")
|
||||
|
||||
def starttls(self, *, context) -> None:
|
||||
assert context.check_hostname
|
||||
calls.append("starttls")
|
||||
|
||||
def login(self, username: str, password: str) -> None:
|
||||
assert username == "operator"
|
||||
assert password == "smtp-password"
|
||||
calls.append("auth")
|
||||
|
||||
def send_message(self, message) -> None:
|
||||
assert "smtp-password" not in message.as_string()
|
||||
calls.append("send")
|
||||
|
||||
event = NotificationEvent(
|
||||
id=str(new_uuid7()),
|
||||
type="execution.queued",
|
||||
schema_version=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
correlation_id=str(new_uuid7()),
|
||||
severity="info",
|
||||
resource_refs={},
|
||||
payload={},
|
||||
canonical_envelope="{}",
|
||||
)
|
||||
settings = NotificationEmailSettings(
|
||||
id=1,
|
||||
host="smtp.example.test",
|
||||
port=587,
|
||||
username="operator",
|
||||
password_secret_id=str(new_uuid7()),
|
||||
sender="sender@example.test",
|
||||
max_attempts=5,
|
||||
rate_limit_per_minute=60,
|
||||
)
|
||||
result = await deliver_email(
|
||||
settings,
|
||||
"smtp-password",
|
||||
event,
|
||||
["operator@example.test"],
|
||||
smtp_factory=cast(Callable[..., SMTPClient], FakeSMTP),
|
||||
)
|
||||
assert result.response_class == "smtp_2xx"
|
||||
assert calls == ["connect", "ehlo", "starttls", "ehlo", "auth", "send", "close"]
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from alembic import command
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.engine import create_engine
|
||||
from backup_tool.db.models import Execution, Job, Repository, Source
|
||||
from backup_tool.execution import (
|
||||
EnqueueError,
|
||||
claim,
|
||||
complete_cancellation,
|
||||
enqueue,
|
||||
heartbeat,
|
||||
request_cancellation,
|
||||
retry,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
cli = importlib.import_module("backup_tool.cli")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def database(
|
||||
tmp_path: Path,
|
||||
) -> AsyncIterator[tuple[async_sessionmaker[AsyncSession], AsyncEngine]]:
|
||||
key = tmp_path / "master.key"
|
||||
key.write_bytes(b"m5-test-master-key-material-32-bytes-minimum")
|
||||
key.chmod(0o600)
|
||||
data_dir = tmp_path / "data"
|
||||
repositories = tmp_path / "repositories"
|
||||
sources = tmp_path / "sources"
|
||||
restores = tmp_path / "restores"
|
||||
for directory in (data_dir, repositories, sources, restores):
|
||||
directory.mkdir()
|
||||
settings = Settings(
|
||||
data_dir=data_dir,
|
||||
database_url=f"sqlite+aiosqlite:///{data_dir / 'metadata.db'}",
|
||||
repository_roots=(repositories,),
|
||||
local_source_roots=(sources,),
|
||||
restore_roots=(restores,),
|
||||
master_key_file=key,
|
||||
)
|
||||
command.upgrade(cli.build_alembic_config(settings), "head")
|
||||
engine = create_engine(settings)
|
||||
yield async_sessionmaker(engine, expire_on_commit=False), engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def create_job(
|
||||
db: AsyncSession, suffix: str, *, enabled: bool = True, state: str = "active"
|
||||
) -> str:
|
||||
repository = Repository(
|
||||
name=f"repository-{suffix}",
|
||||
root=f"/repositories/{suffix}",
|
||||
format_version=1,
|
||||
compression="none",
|
||||
encryption="none",
|
||||
)
|
||||
source = Source(
|
||||
name=f"source-{suffix}",
|
||||
kind="local",
|
||||
public_config={"root": f"/sources/{suffix}"},
|
||||
secret_refs=[],
|
||||
)
|
||||
db.add_all([repository, source])
|
||||
await db.flush()
|
||||
job = Job(
|
||||
name=f"job-{suffix}",
|
||||
source_id=source.id,
|
||||
repository_id=repository.id,
|
||||
requested_mode="full",
|
||||
exclusions=[],
|
||||
retention={},
|
||||
enabled=enabled,
|
||||
state=state,
|
||||
)
|
||||
db.add(job)
|
||||
await db.commit()
|
||||
return job.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_enqueue_allows_exactly_one_active_execution(
|
||||
database: tuple[async_sessionmaker[AsyncSession], AsyncEngine],
|
||||
) -> None:
|
||||
sessions, _ = database
|
||||
async with sessions() as db:
|
||||
job_id = await create_job(db, "concurrent")
|
||||
|
||||
async def start() -> Execution | EnqueueError:
|
||||
async with sessions() as db:
|
||||
try:
|
||||
return await enqueue(db, job_id)
|
||||
except EnqueueError as error:
|
||||
return error
|
||||
|
||||
first, second = await asyncio.gather(start(), start())
|
||||
results = [first, second]
|
||||
successes = [result for result in results if isinstance(result, Execution)]
|
||||
failures = [result for result in results if isinstance(result, EnqueueError)]
|
||||
|
||||
assert len(successes) == 1
|
||||
assert len(failures) == 1
|
||||
assert failures[0].code == "execution_active"
|
||||
assert failures[0].active_execution_id == successes[0].id
|
||||
async with sessions() as db:
|
||||
executions = list(await db.scalars(select(Execution).where(Execution.job_id == job_id)))
|
||||
assert [execution.id for execution in executions] == [successes[0].id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("enabled", "state"), [(False, "active"), (True, "archived")])
|
||||
async def test_enqueue_rejects_disabled_or_archived_jobs(
|
||||
database: tuple[async_sessionmaker[AsyncSession], AsyncEngine],
|
||||
enabled: bool,
|
||||
state: str,
|
||||
) -> None:
|
||||
sessions, _ = database
|
||||
async with sessions() as db:
|
||||
job_id = await create_job(
|
||||
db, f"unavailable-{enabled}-{state}", enabled=enabled, state=state
|
||||
)
|
||||
with pytest.raises(EnqueueError) as raised:
|
||||
await enqueue(db, job_id)
|
||||
|
||||
assert raised.value.code == "job_disabled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reclaimed_lease_fences_the_previous_worker(
|
||||
database: tuple[async_sessionmaker[AsyncSession], AsyncEngine],
|
||||
) -> None:
|
||||
sessions, _ = database
|
||||
async with sessions() as db:
|
||||
execution = await enqueue(db, await create_job(db, "leases"))
|
||||
assert await claim(db, execution.id, "worker-a") is not None
|
||||
assert await heartbeat(db, execution.id, "worker-a")
|
||||
persisted = await db.get(Execution, execution.id)
|
||||
assert persisted is not None
|
||||
persisted.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
await db.commit()
|
||||
|
||||
async with sessions() as db:
|
||||
assert await claim(db, execution.id, "worker-b") is not None
|
||||
assert not await heartbeat(db, execution.id, "worker-a")
|
||||
assert await request_cancellation(db, execution.id) is not None
|
||||
assert not await complete_cancellation(db, execution.id, "worker-a")
|
||||
assert await complete_cancellation(db, execution.id, "worker-b")
|
||||
|
||||
async with sessions() as db:
|
||||
persisted = await db.get(Execution, execution.id)
|
||||
assert persisted is not None
|
||||
assert persisted.state == "cancelled"
|
||||
assert persisted.lease_owner is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_reuses_execution_and_rejects_non_transient_failures(
|
||||
database: tuple[async_sessionmaker[AsyncSession], AsyncEngine],
|
||||
) -> None:
|
||||
sessions, _ = database
|
||||
async with sessions() as db:
|
||||
execution = await enqueue(db, await create_job(db, "retry"))
|
||||
execution.state = "failed"
|
||||
execution.reason_code = "transient_io"
|
||||
execution.operator_message = "temporary failure"
|
||||
execution.lease_owner = "worker-a"
|
||||
execution.lease_expires_at = datetime.now(UTC) + timedelta(seconds=60)
|
||||
await db.commit()
|
||||
|
||||
retried = await retry(db, execution.id)
|
||||
assert retried is not None
|
||||
assert retried.id == execution.id
|
||||
assert retried.state == "queued"
|
||||
assert retried.attempt == 2
|
||||
assert retried.reason_code is None
|
||||
assert retried.operator_message is None
|
||||
assert retried.lease_owner is None
|
||||
assert retried.lease_expires_at is None
|
||||
|
||||
retried.state = "failed"
|
||||
retried.reason_code = "integrity_failure"
|
||||
await db.commit()
|
||||
with pytest.raises(EnqueueError) as raised:
|
||||
await retry(db, execution.id)
|
||||
|
||||
assert raised.value.code == "retry_not_allowed"
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from backup_tool.cli import (
|
||||
build_alembic_config,
|
||||
import_recovery_payload,
|
||||
)
|
||||
from backup_tool.cli import (
|
||||
main as cli_main,
|
||||
)
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.engine import create_engine
|
||||
from backup_tool.db.models import (
|
||||
Backup,
|
||||
Execution,
|
||||
Job,
|
||||
Repository,
|
||||
RepositoryDataKeyEpoch,
|
||||
Restore,
|
||||
Source,
|
||||
)
|
||||
from backup_tool.ids import new_uuid7
|
||||
from backup_tool.repository import initialize
|
||||
from backup_tool.security.recovery_bundle import RecoveryBundleError, decrypt_bundle
|
||||
from backup_tool.snapshot import finalize_publication, publish_full_snapshot
|
||||
from backup_tool.worker import Worker
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from tests.conftest import make_settings
|
||||
|
||||
PASSPHRASE = b"correct horse battery staple"
|
||||
|
||||
|
||||
def _passphrase_fd(value: bytes) -> int:
|
||||
read_fd, write_fd = os.pipe()
|
||||
os.write(write_fd, value + b"\n")
|
||||
os.close(write_fd)
|
||||
return read_fd
|
||||
|
||||
|
||||
def _recovered_settings(tmp_path: Path, original: Settings) -> Settings:
|
||||
data_dir = tmp_path / "recovered-data"
|
||||
source_root = tmp_path / "recovered-sources"
|
||||
restore_root = tmp_path / "recovered-restores"
|
||||
for path in (data_dir, source_root, restore_root):
|
||||
path.mkdir()
|
||||
master_key = tmp_path / "recovered-master.key"
|
||||
master_key.write_bytes(b"recovered-host-master-key-material-32-bytes")
|
||||
master_key.chmod(0o600)
|
||||
return Settings(
|
||||
data_dir=data_dir,
|
||||
database_url=f"sqlite+aiosqlite:///{data_dir / 'metadata.db'}",
|
||||
repository_roots=original.repository_roots,
|
||||
local_source_roots=(source_root,),
|
||||
restore_roots=(restore_root,),
|
||||
master_key_file=master_key,
|
||||
min_free_bytes=1,
|
||||
)
|
||||
|
||||
|
||||
def test_recovery_import_restores_encrypted_snapshot_after_host_loss(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
original_path = tmp_path / "original"
|
||||
original_path.mkdir()
|
||||
original = make_settings(original_path)
|
||||
command.upgrade(build_alembic_config(original), "head")
|
||||
source_root = original.local_source_roots[0] / "project"
|
||||
source_root.mkdir()
|
||||
plaintext = b"recovery host-loss content\n"
|
||||
(source_root / "document.txt").write_bytes(plaintext)
|
||||
initialized = initialize(original, "encrypted", "none", "aes-256-gcm")
|
||||
assert initialized.data_key_id is not None
|
||||
|
||||
expected_created_at = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC)
|
||||
expected_tombstoned_at = datetime(2024, 2, 3, 4, 5, 6, tzinfo=UTC)
|
||||
|
||||
async def create_snapshot() -> tuple[str, str]:
|
||||
engine = create_engine(original)
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
try:
|
||||
async with sessions() as db:
|
||||
repository = Repository(
|
||||
name="encrypted",
|
||||
root=str(initialized.root),
|
||||
format_version=initialized.format_version,
|
||||
compression=initialized.compression,
|
||||
encryption=initialized.encryption,
|
||||
signing_key_id=initialized.signing_key_id,
|
||||
signing_public_key=initialized.signing_public_key,
|
||||
active_data_key_id=initialized.data_key_id,
|
||||
)
|
||||
source = Source(
|
||||
name="project",
|
||||
kind="local",
|
||||
public_config={"root": str(source_root)},
|
||||
secret_refs=[],
|
||||
)
|
||||
db.add_all([repository, source])
|
||||
await db.flush()
|
||||
db.add(
|
||||
RepositoryDataKeyEpoch(
|
||||
repository_id=repository.id,
|
||||
key_id=initialized.data_key_id,
|
||||
state="active",
|
||||
)
|
||||
)
|
||||
job = Job(
|
||||
name="encrypted-job",
|
||||
source_id=source.id,
|
||||
repository_id=repository.id,
|
||||
requested_mode="full",
|
||||
exclusions=[],
|
||||
retention={},
|
||||
allow_empty=False,
|
||||
)
|
||||
db.add(job)
|
||||
await db.flush()
|
||||
execution = Execution(job_id=job.id, trigger="manual", progress={})
|
||||
db.add(execution)
|
||||
await db.flush()
|
||||
backup = await publish_full_snapshot(
|
||||
original, db, execution, job, source, repository
|
||||
)
|
||||
backup.created_at = expected_created_at
|
||||
execution.state = "committed"
|
||||
tombstoned_execution = Execution(job_id=job.id, trigger="manual", progress={})
|
||||
db.add(tombstoned_execution)
|
||||
await db.flush()
|
||||
tombstoned_backup = Backup(
|
||||
execution_id=tombstoned_execution.id,
|
||||
parent_backup_id=None,
|
||||
manifest_id=str(new_uuid7()),
|
||||
manifest_digest="0" * 64,
|
||||
logical_bytes=0,
|
||||
stored_bytes=0,
|
||||
integrity="verified",
|
||||
data_key_id=initialized.data_key_id,
|
||||
tombstoned_at=expected_tombstoned_at,
|
||||
created_at=expected_created_at,
|
||||
)
|
||||
db.add(tombstoned_backup)
|
||||
await db.commit()
|
||||
finalize_publication(initialized.root, execution.id)
|
||||
return backup.id, tombstoned_backup.id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
original_backup_id, tombstoned_backup_id = asyncio.run(create_snapshot())
|
||||
bundle = tmp_path / "offline.btrec"
|
||||
export_fd = _passphrase_fd(PASSPHRASE)
|
||||
try:
|
||||
assert (
|
||||
cli_main(
|
||||
[
|
||||
"admin",
|
||||
"recovery",
|
||||
"export",
|
||||
"--output",
|
||||
str(bundle),
|
||||
"--passphrase-fd",
|
||||
str(export_fd),
|
||||
],
|
||||
settings=original,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
os.close(export_fd)
|
||||
|
||||
retry_path = tmp_path / "retry-recovery"
|
||||
retry_path.mkdir()
|
||||
retry = _recovered_settings(retry_path, original)
|
||||
command.upgrade(build_alembic_config(retry), "head")
|
||||
retry_payload = decrypt_bundle(bundle.read_bytes(), PASSPHRASE)
|
||||
|
||||
def interrupted_after_key_install() -> None:
|
||||
raise OSError("simulated crash before metadata commit")
|
||||
|
||||
with pytest.raises(RecoveryBundleError, match="recovery import failed"):
|
||||
asyncio.run(
|
||||
import_recovery_payload(
|
||||
retry,
|
||||
retry_payload,
|
||||
after_key_install=interrupted_after_key_install,
|
||||
)
|
||||
)
|
||||
assert not list((retry.data_dir / "repository-keys").glob("*"))
|
||||
assert not list((retry.data_dir / "repository-data-keys").glob("*"))
|
||||
assert asyncio.run(import_recovery_payload(retry, retry_payload)) == 1
|
||||
|
||||
unsafe_path = tmp_path / "unsafe-recovery"
|
||||
unsafe_path.mkdir()
|
||||
unsafe = _recovered_settings(unsafe_path, original)
|
||||
command.upgrade(build_alembic_config(unsafe), "head")
|
||||
unsafe_payload = decrypt_bundle(bundle.read_bytes(), PASSPHRASE)
|
||||
unsafe_payload["catalog"]["repositories"][0]["root"] = str(tmp_path)
|
||||
with pytest.raises(RecoveryBundleError, match="recovery import failed"):
|
||||
asyncio.run(import_recovery_payload(unsafe, unsafe_payload))
|
||||
assert not (unsafe.data_dir / "repository-keys").exists()
|
||||
assert not (unsafe.data_dir / "repository-data-keys").exists()
|
||||
|
||||
recovered = _recovered_settings(tmp_path, original)
|
||||
command.upgrade(build_alembic_config(recovered), "head")
|
||||
import_fd = _passphrase_fd(PASSPHRASE)
|
||||
try:
|
||||
assert (
|
||||
cli_main(
|
||||
[
|
||||
"admin",
|
||||
"recovery",
|
||||
"import",
|
||||
"--input",
|
||||
str(bundle),
|
||||
"--passphrase-fd",
|
||||
str(import_fd),
|
||||
],
|
||||
settings=recovered,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
os.close(import_fd)
|
||||
|
||||
async def restore_and_assert() -> None:
|
||||
engine = create_engine(recovered)
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
try:
|
||||
async with sessions() as db:
|
||||
backup = await db.get(Backup, original_backup_id)
|
||||
tombstoned_backup = await db.get(Backup, tombstoned_backup_id)
|
||||
job = await db.scalar(select(Job))
|
||||
recovered_source = await db.scalar(select(Source))
|
||||
assert backup is not None
|
||||
assert tombstoned_backup is not None
|
||||
assert backup.created_at == expected_created_at
|
||||
assert tombstoned_backup.created_at == expected_created_at
|
||||
assert tombstoned_backup.tombstoned_at == expected_tombstoned_at
|
||||
assert job is not None
|
||||
assert recovered_source is not None
|
||||
assert recovered_source.state == "unavailable"
|
||||
assert job.state == "archived"
|
||||
assert not job.enabled
|
||||
restore = Restore(
|
||||
backup_id=backup.id,
|
||||
destination=str(recovered.restore_roots[0] / "restored"),
|
||||
selection=[],
|
||||
overwrite_policy="fail",
|
||||
)
|
||||
db.add(restore)
|
||||
await db.commit()
|
||||
worker = Worker(recovered, owner="host-loss-restore")
|
||||
try:
|
||||
assert await worker.run_once()
|
||||
finally:
|
||||
await worker.engine.dispose()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(restore_and_assert())
|
||||
assert (recovered.restore_roots[0] / "restored" / "document.txt").read_bytes() == plaintext
|
||||
|
||||
payload = decrypt_bundle(bundle.read_bytes(), PASSPHRASE)
|
||||
with pytest.raises(RecoveryBundleError, match="destination is not empty"):
|
||||
asyncio.run(import_recovery_payload(recovered, payload))
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from backup_tool.api.app import create_app
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.models import RepositoryDataKeyEpoch
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -30,7 +33,7 @@ async def test_admin_can_create_and_inspect_allowlisted_repository(
|
||||
|
||||
async with app.state.engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
transport = httpx.ASGITransport(app=cast(Any, app))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
setup = await client.post(
|
||||
"/api/v2/setup", json={"username": "admin", "password": "a secure password"}
|
||||
@@ -52,6 +55,29 @@ async def test_admin_can_create_and_inspect_allowlisted_repository(
|
||||
assert body["name"] == "main"
|
||||
assert body["format_version"] == 1
|
||||
assert (root / "main" / "repository.json").is_file()
|
||||
encrypted = await client.post(
|
||||
"/api/v2/repositories",
|
||||
json={
|
||||
"name": "encrypted",
|
||||
"relative_path": "encrypted",
|
||||
"compression": "none",
|
||||
"encryption": "aes-256-gcm",
|
||||
},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert encrypted.status_code == 201, encrypted.text
|
||||
async with app.state.sessions() as db:
|
||||
epochs = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(RepositoryDataKeyEpoch).where(
|
||||
RepositoryDataKeyEpoch.repository_id == encrypted.json()["id"]
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(epochs) == 1
|
||||
assert epochs[0].state == "active"
|
||||
got = await client.get(f"/api/v2/repositories/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
changed = await client.patch(
|
||||
|
||||
@@ -42,6 +42,7 @@ def test_partial_initialization_is_removed_on_publish_failure(tmp_path: Path) ->
|
||||
initialize(settings, "main", "none", "none")
|
||||
assert not (settings.repository_roots[0] / "main").exists()
|
||||
assert not list(settings.repository_roots[0].glob(".main.staging-*"))
|
||||
assert not list((settings.data_dir / "repository-keys").glob("*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relative_path", ["/absolute", "../escape"])
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from backup_tool.api.app import create_app
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.models import Repository
|
||||
from backup_tool.repository import initialize
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
PASSWORD = "a secure password"
|
||||
|
||||
|
||||
class SignedRepository(Protocol):
|
||||
repository_id: str
|
||||
signing_key_id: str
|
||||
signing_public_key: str
|
||||
|
||||
|
||||
def settings_for(tmp_path: Path) -> Settings:
|
||||
key = tmp_path / "master.key"
|
||||
key.write_bytes(b"m6-test-master-key-material-32-bytes-minimum")
|
||||
key.chmod(0o600)
|
||||
repositories = tmp_path / "repositories"
|
||||
repositories.mkdir()
|
||||
return Settings(
|
||||
data_dir=tmp_path,
|
||||
database_url=f"sqlite+aiosqlite:///{tmp_path / 'metadata.db'}",
|
||||
repository_roots=(repositories,),
|
||||
local_source_roots=(tmp_path,),
|
||||
restore_roots=(tmp_path,),
|
||||
master_key_file=key,
|
||||
min_free_bytes=1,
|
||||
)
|
||||
|
||||
|
||||
def test_repository_initialization_creates_a_bound_ed25519_keypair(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
|
||||
initialized = cast(SignedRepository, initialize(settings, "main", "none", "none"))
|
||||
|
||||
key_path = settings.data_dir / "repository-keys" / f"{initialized.repository_id}.ed25519"
|
||||
assert key_path.read_bytes()
|
||||
assert key_path.stat().st_mode & 0o777 == 0o600
|
||||
private_key = Ed25519PrivateKey.from_private_bytes(key_path.read_bytes())
|
||||
public_key = private_key.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
assert initialized.signing_public_key == public_key.hex()
|
||||
assert initialized.signing_key_id.startswith("ed25519-")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_api_persists_its_bound_public_signing_key(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
app = create_app(settings)
|
||||
from backup_tool.db.models import Base
|
||||
|
||||
async with app.state.engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="https://test"
|
||||
) as client:
|
||||
assert (
|
||||
await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||
).status_code == 201
|
||||
created = await client.post(
|
||||
"/api/v2/repositories",
|
||||
json={
|
||||
"name": "main",
|
||||
"relative_path": "main",
|
||||
"compression": "none",
|
||||
"encryption": "none",
|
||||
},
|
||||
headers={"X-CSRF-Token": client.cookies["backup_tool_csrf"]},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
async with app.state.sessions() as db:
|
||||
repository = await db.get(Repository, created.json()["id"])
|
||||
|
||||
await app.state.engine.dispose()
|
||||
assert repository is not None
|
||||
assert repository.signing_key_id.startswith("ed25519-")
|
||||
assert len(repository.signing_public_key) == 64
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.models import NotificationDelivery, NotificationEvent, Schedule
|
||||
from backup_tool.scheduler import SchedulerService
|
||||
from sqlalchemy import select
|
||||
|
||||
PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
async def setup_job(client: httpx.AsyncClient, settings: Settings) -> tuple[dict[str, str], str]:
|
||||
source_root = settings.local_source_roots[0] / "source"
|
||||
source_root.mkdir()
|
||||
headers = await login(client)
|
||||
repository = await client.post(
|
||||
"/api/v2/repositories",
|
||||
json={
|
||||
"name": "repo",
|
||||
"relative_path": "repo",
|
||||
"compression": "none",
|
||||
"encryption": "none",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
source = await client.post(
|
||||
"/api/v2/sources",
|
||||
json={
|
||||
"name": "source",
|
||||
"kind": "local",
|
||||
"public_config": {"root": str(source_root)},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
job = await client.post(
|
||||
"/api/v2/jobs",
|
||||
json={
|
||||
"name": "job",
|
||||
"source_id": source.json()["id"],
|
||||
"repository_id": repository.json()["id"],
|
||||
"requested_mode": "full",
|
||||
"exclusions": [],
|
||||
"retention": {},
|
||||
"enabled": True,
|
||||
"allow_empty": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert job.status_code == 201
|
||||
return headers, job.json()["id"]
|
||||
|
||||
|
||||
async def login(client: httpx.AsyncClient) -> dict[str, str]:
|
||||
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||
assert response.status_code == 201
|
||||
return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_role_delivery_and_live_crud_sync(
|
||||
app_client: tuple[httpx.AsyncClient, Settings],
|
||||
) -> None:
|
||||
client, settings = app_client
|
||||
headers, job_id = await setup_job(client, settings)
|
||||
subscription = await client.post(
|
||||
"/api/v2/notifications/subscriptions",
|
||||
json={
|
||||
"channel": "email",
|
||||
"event_filters": ["schedule.occurrence_enqueued"],
|
||||
"destination": {"recipients": ["operator@example.test"]},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert subscription.status_code == 201
|
||||
created = await client.post(
|
||||
f"/api/v2/jobs/{job_id}/schedule",
|
||||
json={"cron": "* * * * *", "timezone": "UTC"},
|
||||
headers=headers,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
app = cast(Any, client._transport).app
|
||||
async with app.state.sessions() as db:
|
||||
schedule = await db.scalar(select(Schedule).where(Schedule.job_id == job_id))
|
||||
assert schedule is not None
|
||||
schedule.next_nominal_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
await db.commit()
|
||||
service = SchedulerService(settings)
|
||||
try:
|
||||
assert await service.run_once() == 1
|
||||
finally:
|
||||
await service.engine.dispose()
|
||||
async with app.state.sessions() as db:
|
||||
delivery = await db.scalar(
|
||||
select(NotificationDelivery.id)
|
||||
.join(NotificationEvent, NotificationDelivery.event_id == NotificationEvent.id)
|
||||
.where(NotificationEvent.type == "schedule.occurrence_enqueued")
|
||||
.limit(1)
|
||||
)
|
||||
assert delivery is not None
|
||||
|
||||
updated = await client.patch(
|
||||
f"/api/v2/jobs/{job_id}/schedule",
|
||||
json={"cron": "0 10 * * *", "timezone": "UTC", "enabled": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["next_nominal_at"] is None
|
||||
# Deletion with historical executions is deliberately restricted; schedule
|
||||
# delete behavior is covered before occurrence enqueue in the catalog test.
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -18,7 +19,6 @@ from backup_tool.execution import (
|
||||
recover_stale,
|
||||
request_cancellation,
|
||||
)
|
||||
from backup_tool.worker import Worker
|
||||
|
||||
PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
@@ -58,7 +58,7 @@ async def test_local_source_probe_archive_and_repository_targeted_job(
|
||||
|
||||
async with app.state.engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
transport = httpx.ASGITransport(app=cast(Any, app))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
headers = await login(client)
|
||||
repository = await client.post(
|
||||
@@ -108,6 +108,24 @@ async def test_local_source_probe_archive_and_repository_targeted_job(
|
||||
assert duplicate.status_code == 409
|
||||
assert duplicate.json()["code"] == "execution_active"
|
||||
execution_id = execution.json()["id"]
|
||||
listed_sources = await client.get("/api/v2/sources", headers=headers)
|
||||
assert listed_sources.status_code == 200
|
||||
assert listed_sources.json()["items"] == [
|
||||
{
|
||||
"id": source_id,
|
||||
"name": "local",
|
||||
"kind": "local",
|
||||
"state": "active",
|
||||
"public_config": {"root": str(source_root)},
|
||||
}
|
||||
]
|
||||
listed_jobs = await client.get("/api/v2/jobs", headers=headers)
|
||||
assert listed_jobs.status_code == 200
|
||||
assert listed_jobs.json()["items"][0]["id"] == job.json()["id"]
|
||||
assert listed_jobs.json()["items"][0]["schedule"] is None
|
||||
listed_executions = await client.get("/api/v2/executions", headers=headers)
|
||||
assert listed_executions.status_code == 200
|
||||
assert listed_executions.json()["items"][0]["id"] == execution_id
|
||||
scoped_token = await client.post(
|
||||
"/api/v2/auth/tokens",
|
||||
json={"scopes": ["audit:read"], "expires_at": None},
|
||||
@@ -252,7 +270,9 @@ async def test_execution_sse_replays_later_redacted_revision(tmp_path: Path) ->
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_events_preserve_progress_replay_and_recovery_order(tmp_path: Path) -> None:
|
||||
async def test_execution_events_preserve_progress_replay_and_recovery_order(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source_root = tmp_path / "sources"
|
||||
source_root.mkdir()
|
||||
data_dir = tmp_path / "data"
|
||||
@@ -306,8 +326,14 @@ async def test_execution_events_preserve_progress_replay_and_recovery_order(tmp_
|
||||
execution = await enqueue(db, job.id)
|
||||
execution_id = execution.id
|
||||
|
||||
worker = Worker(settings, owner="ordering-worker")
|
||||
assert await worker.run_once()
|
||||
async with app.state.sessions() as db:
|
||||
assert await claim(db, execution_id, "ordering-worker") is not None
|
||||
execution = await db.get(Execution, execution_id)
|
||||
assert execution is not None and execution.state == "preparing"
|
||||
execution.state = "running"
|
||||
execution.started_at = datetime.now(UTC)
|
||||
await record_event(db, execution)
|
||||
await db.commit()
|
||||
async with app.state.sessions() as db:
|
||||
execution = await db.get(Execution, execution_id)
|
||||
assert execution is not None and execution.state == "running"
|
||||
@@ -383,7 +409,7 @@ async def test_local_source_rejects_unallowlisted_root(tmp_path: Path) -> None:
|
||||
async with app.state.engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="https://test"
|
||||
transport=httpx.ASGITransport(app=cast(Any, app)), base_url="https://test"
|
||||
) as client:
|
||||
headers = await login(client)
|
||||
response = await client.post(
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Opt-in live forced-SFTP chroot coverage; all SSH keys are generated under tmp_path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from backup_tool.db.engine import create_engine
|
||||
from backup_tool.db.models import Backup, Execution, Repository
|
||||
from backup_tool.snapshot import verify_published_snapshot
|
||||
from backup_tool.worker import Worker
|
||||
from sqlalchemy import select
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.environ.get("BACKUP_TOOL_SSH_INTEGRATION") == "1"
|
||||
|
||||
|
||||
def _port() -> int:
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
address = listener.getsockname()
|
||||
if not isinstance(address, tuple) or not isinstance(address[1], int):
|
||||
raise RuntimeError("could not allocate SSH fixture port")
|
||||
return address[1]
|
||||
|
||||
|
||||
def _wait(port: int) -> None:
|
||||
deadline = time.monotonic() + 60
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.25)
|
||||
raise AssertionError("SSHD fixture did not become reachable")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sshd_fixture(tmp_path: Path):
|
||||
fixture = tmp_path / "fixture"
|
||||
host = fixture / "host"
|
||||
source = fixture / "source"
|
||||
host.mkdir(parents=True)
|
||||
source.mkdir()
|
||||
fixture.chmod(0o755)
|
||||
host.chmod(0o755)
|
||||
source.chmod(0o755)
|
||||
private = fixture / "client"
|
||||
for target in (host / "ssh_host_ed25519_key", private):
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(target)],
|
||||
check=True,
|
||||
)
|
||||
(host / "ssh_host_ed25519_key").chmod(0o644)
|
||||
(fixture / "authorized_keys").write_text(private.with_suffix(".pub").read_text())
|
||||
# Public keys are copied into a backup-owned 0600 tmpfs file at startup.
|
||||
(fixture / "authorized_keys").chmod(0o644)
|
||||
port = _port()
|
||||
environment = os.environ | {
|
||||
"SSH_FIXTURE_DIR": str(fixture),
|
||||
"SSH_FIXTURE_PORT": str(port),
|
||||
"COMPOSE_PROJECT_NAME": f"backup-tool-ssh-{os.getpid()}-{port}",
|
||||
}
|
||||
command = ["docker", "compose", "-f", "tests/compose.ssh.yaml"]
|
||||
try:
|
||||
subprocess.run([*command, "up", "--build", "-d"], cwd=ROOT, env=environment, check=True)
|
||||
_wait(port)
|
||||
host_key = " ".join(host.joinpath("ssh_host_ed25519_key.pub").read_text().split()[:2])
|
||||
yield {
|
||||
"port": port,
|
||||
"key": private,
|
||||
"host_key": host_key,
|
||||
"source": source,
|
||||
"env": environment,
|
||||
}
|
||||
finally:
|
||||
subprocess.run(
|
||||
[*command, "down", "--volumes", "--remove-orphans"],
|
||||
cwd=ROOT,
|
||||
env=environment,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
async def _login(client: httpx.AsyncClient) -> dict[str, str]:
|
||||
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||
assert response.status_code == 201
|
||||
return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _enabled(), reason="set BACKUP_TOOL_SSH_INTEGRATION=1")
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_sftp_chroot_probe_backup_and_restore(app_client, sshd_fixture) -> None:
|
||||
client, settings = app_client
|
||||
source = sshd_fixture["source"]
|
||||
(source / "nested").mkdir()
|
||||
(source / "nested" / "hello.txt").write_text("hello ssh\n")
|
||||
headers = await _login(client)
|
||||
secret = await client.post(
|
||||
"/api/v2/admin/secrets",
|
||||
json={"purpose": "ssh_private_key", "value": sshd_fixture["key"].read_text()},
|
||||
headers=headers,
|
||||
)
|
||||
assert secret.status_code == 201
|
||||
remote = await client.post(
|
||||
"/api/v2/sources",
|
||||
json={
|
||||
"name": "ssh",
|
||||
"kind": "ssh",
|
||||
"private_key_secret_id": secret.json()["id"],
|
||||
"public_config": {
|
||||
"hostname": "127.0.0.1",
|
||||
"port": sshd_fixture["port"],
|
||||
"username": "backup",
|
||||
"host_key": sshd_fixture["host_key"],
|
||||
"root": "/",
|
||||
},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert remote.status_code == 201, remote.text
|
||||
assert (
|
||||
await client.post(f"/api/v2/sources/{remote.json()['id']}/probe", headers=headers)
|
||||
).json() == {"entry_count": 1}
|
||||
repository = await client.post(
|
||||
"/api/v2/repositories",
|
||||
json={"name": "repo", "relative_path": "ssh"},
|
||||
headers=headers,
|
||||
)
|
||||
job = await client.post(
|
||||
"/api/v2/jobs",
|
||||
json={
|
||||
"name": "ssh-job",
|
||||
"source_id": remote.json()["id"],
|
||||
"repository_id": repository.json()["id"],
|
||||
"requested_mode": "full",
|
||||
"exclusions": [],
|
||||
"retention": {},
|
||||
"enabled": True,
|
||||
"allow_empty": False,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
execution = await client.post(f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers)
|
||||
worker = Worker(settings, owner="ssh-live")
|
||||
try:
|
||||
assert await worker.run_once()
|
||||
finally:
|
||||
await worker.engine.dispose()
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as db:
|
||||
stored = await db.get(Execution, execution.json()["id"])
|
||||
backup = await db.scalar(
|
||||
select(Backup).where(Backup.execution_id == execution.json()["id"])
|
||||
)
|
||||
stored_repository = await db.get(Repository, repository.json()["id"])
|
||||
finally:
|
||||
await engine.dispose()
|
||||
assert stored is not None and stored.state == "committed", (
|
||||
stored.operator_message if stored else None
|
||||
)
|
||||
assert backup is not None and stored_repository is not None
|
||||
manifest = verify_published_snapshot(
|
||||
Path(stored_repository.root),
|
||||
Path(stored_repository.root) / "manifests" / f"{backup.manifest_id}.json",
|
||||
stored_repository.signing_public_key,
|
||||
)
|
||||
assert any(entry["path"] == "data/nested/hello.txt" for entry in manifest["entries"])
|
||||
destination = settings.restore_roots[0] / "ssh-restored"
|
||||
restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(destination),
|
||||
"selection": ["data/nested"],
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert restore.status_code == 202
|
||||
restore_worker = Worker(settings, owner="ssh-restore")
|
||||
try:
|
||||
assert await restore_worker.run_once()
|
||||
finally:
|
||||
await restore_worker.engine.dispose()
|
||||
assert (destination / "data" / "nested" / "hello.txt").read_text() == "hello ssh\n"
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
HOST_KEY = "ssh-ed25519 AQID"
|
||||
|
||||
|
||||
async def _setup_headers(client) -> dict[str, str]:
|
||||
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||
assert response.status_code == 201
|
||||
return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
|
||||
|
||||
|
||||
async def _create_secret(client, headers: dict[str, str], purpose: str) -> str:
|
||||
response = await client.post(
|
||||
"/api/v2/admin/secrets",
|
||||
json={"purpose": purpose, "value": "PRIVATE-KEY-CANARY"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert "PRIVATE-KEY-CANARY" not in response.text
|
||||
return str(response.json()["id"])
|
||||
|
||||
|
||||
def _source(secret_id: str) -> dict[str, object]:
|
||||
return {
|
||||
"name": "remote",
|
||||
"kind": "ssh",
|
||||
"public_config": {
|
||||
"hostname": "backup.example.test",
|
||||
"port": 22,
|
||||
"username": "backup",
|
||||
"host_key": HOST_KEY,
|
||||
"root": "/",
|
||||
},
|
||||
"private_key_secret_id": secret_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssh_source_persists_only_safe_public_config_and_one_key_reference(
|
||||
app_client,
|
||||
) -> None:
|
||||
client, _ = app_client
|
||||
headers = await _setup_headers(client)
|
||||
secret_id = await _create_secret(client, headers, "ssh_private_key")
|
||||
|
||||
created = await client.post("/api/v2/sources", json=_source(secret_id), headers=headers)
|
||||
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["kind"] == "ssh"
|
||||
assert body["public_config"] == _source(secret_id)["public_config"]
|
||||
assert "secret" not in body
|
||||
assert "PRIVATE-KEY-CANARY" not in created.text
|
||||
listed = await client.get("/api/v2/sources", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["items"] == [body]
|
||||
assert "PRIVATE-KEY-CANARY" not in listed.text
|
||||
probe = await client.post(f"/api/v2/sources/{body['id']}/probe", headers=headers)
|
||||
assert probe.status_code == 409
|
||||
assert probe.json()["code"] == "source_probe_failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssh_source_requires_exactly_one_existing_private_key_secret(
|
||||
app_client,
|
||||
) -> None:
|
||||
client, _ = app_client
|
||||
headers = await _setup_headers(client)
|
||||
wrong_purpose = await _create_secret(client, headers, "ssh")
|
||||
request = _source(wrong_purpose)
|
||||
|
||||
rejected_purpose = await client.post("/api/v2/sources", json=request, headers=headers)
|
||||
assert rejected_purpose.status_code == 422
|
||||
assert rejected_purpose.json()["code"] == "validation_failed"
|
||||
assert "PRIVATE-KEY-CANARY" not in rejected_purpose.text
|
||||
|
||||
missing_secret = await client.post(
|
||||
"/api/v2/sources",
|
||||
json=_source("00000000-0000-0000-0000-000000000000"),
|
||||
headers=headers,
|
||||
)
|
||||
assert missing_secret.status_code == 422
|
||||
assert missing_secret.json()["code"] == "validation_failed"
|
||||
|
||||
valid_secret = await _create_secret(client, headers, "ssh_private_key")
|
||||
extra_secret_reference = {
|
||||
**_source(valid_secret),
|
||||
"secret_refs": [valid_secret, wrong_purpose],
|
||||
}
|
||||
rejected_extra = await client.post(
|
||||
"/api/v2/sources", json=extra_secret_reference, headers=headers
|
||||
)
|
||||
assert rejected_extra.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"name": "unsupported", "kind": "sftp", "public_config": {}},
|
||||
{"name": "unsupported", "kind": "postgresql", "public_config": {}},
|
||||
{"name": "unsupported", "kind": "mysql", "public_config": {}},
|
||||
{"name": "unsupported", "kind": "shell", "public_config": {}},
|
||||
],
|
||||
)
|
||||
async def test_source_api_rejects_all_non_local_ssh_kinds(
|
||||
app_client, payload: dict[str, object]
|
||||
) -> None:
|
||||
client, _ = app_client
|
||||
headers = await _setup_headers(client)
|
||||
|
||||
response = await client.post("/api/v2/sources", json=payload, headers=headers)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"public_config",
|
||||
[
|
||||
{
|
||||
"hostname": "backup.example.test",
|
||||
"port": 22,
|
||||
"username": "backup",
|
||||
"root": "/",
|
||||
},
|
||||
{
|
||||
"hostname": "backup.example.test",
|
||||
"port": 0,
|
||||
"username": "backup",
|
||||
"host_key": HOST_KEY,
|
||||
"root": "/",
|
||||
},
|
||||
{
|
||||
"hostname": "backup.example.test",
|
||||
"port": 22,
|
||||
"username": "backup",
|
||||
"host_key": HOST_KEY,
|
||||
"root": "/not-the-chroot",
|
||||
},
|
||||
{
|
||||
"hostname": "backup.example.test",
|
||||
"port": 22,
|
||||
"username": "backup",
|
||||
"host_key": HOST_KEY,
|
||||
"root": "/",
|
||||
"password": "not-supported",
|
||||
},
|
||||
{
|
||||
"hostname": "backup.example.test",
|
||||
"port": 22,
|
||||
"username": "backup",
|
||||
"host_key": HOST_KEY,
|
||||
"root": "/",
|
||||
"remote_command": "not-supported",
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_ssh_source_rejects_noncanonical_or_unsupported_config(
|
||||
app_client, public_config: dict[str, object]
|
||||
) -> None:
|
||||
client, _ = app_client
|
||||
headers = await _setup_headers(client)
|
||||
secret_id = await _create_secret(client, headers, "ssh_private_key")
|
||||
payload = _source(secret_id)
|
||||
payload["public_config"] = public_config
|
||||
|
||||
response = await client.post("/api/v2/sources", json=payload, headers=headers)
|
||||
|
||||
assert response.status_code == 422
|
||||
Reference in New Issue
Block a user