feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from backup_tool.scheduler import ScheduleError, next_nominal
|
||||
|
||||
|
||||
def test_five_field_cron_returns_utc_nominal_time() -> None:
|
||||
result = next_nominal("0 9 * * *", "America/New_York", datetime(2026, 1, 1, tzinfo=UTC))
|
||||
assert result.tzinfo is UTC
|
||||
assert result.hour == 14
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("cron", "timezone"), [("* * * * * *", "UTC"), ("* * * * *", "Nope/Zone")])
|
||||
def test_invalid_cron_or_timezone_is_rejected(cron: str, timezone: str) -> None:
|
||||
with pytest.raises(ScheduleError):
|
||||
next_nominal(cron, timezone)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from backup_tool.exclusions import ExclusionError, matches
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "patterns", "expected"),
|
||||
[
|
||||
("notes.tmp", ["*.tmp"], True),
|
||||
("nested/notes.tmp", ["*.tmp"], True),
|
||||
("cache/item.txt", ["cache/"], True),
|
||||
("cache/keep.txt", ["cache/", "!cache/keep.txt"], False),
|
||||
("data/keep.txt", ["data/**"], True),
|
||||
("data.txt", ["data/**"], False),
|
||||
],
|
||||
)
|
||||
def test_gitignore_like_exclusions(path: str, patterns: list[str], expected: bool) -> None:
|
||||
assert matches(path, patterns) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["../escape", "/absolute", "windows\\path", ""])
|
||||
def test_exclusions_reject_unsafe_paths(value: str) -> None:
|
||||
with pytest.raises(ExclusionError):
|
||||
matches("safe.txt", [value])
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from backup_tool.execution import TransitionError, transition
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current", "target"),
|
||||
[
|
||||
("queued", "preparing"),
|
||||
("queued", "cancelled"),
|
||||
("queued", "failed"),
|
||||
("preparing", "running"),
|
||||
("preparing", "cancelling"),
|
||||
("preparing", "failed"),
|
||||
("running", "verifying"),
|
||||
("running", "cancelling"),
|
||||
("running", "failed"),
|
||||
("verifying", "committed"),
|
||||
("verifying", "failed"),
|
||||
("cancelling", "cancelled"),
|
||||
("cancelling", "failed"),
|
||||
],
|
||||
)
|
||||
def test_all_legal_execution_transitions_are_accepted(current: str, target: str) -> None:
|
||||
assert transition(current, target) == target
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current", "target"),
|
||||
[
|
||||
("queued", "running"),
|
||||
("preparing", "queued"),
|
||||
("running", "preparing"),
|
||||
("verifying", "cancelling"),
|
||||
("cancelling", "running"),
|
||||
("committed", "cancelled"),
|
||||
("cancelled", "queued"),
|
||||
("failed", "queued"),
|
||||
("unknown", "queued"),
|
||||
],
|
||||
)
|
||||
def test_invalid_or_backward_execution_transitions_are_rejected(current: str, target: str) -> None:
|
||||
with pytest.raises(TransitionError) as raised:
|
||||
transition(current, target)
|
||||
|
||||
assert raised.value.code == "invalid_transition"
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
snapshot = importlib.import_module("backup_tool.snapshot")
|
||||
|
||||
|
||||
def entry(path: str, kind: str) -> dict[str, object]:
|
||||
return {"path": path, "type": kind}
|
||||
|
||||
|
||||
def test_restore_selection_includes_selected_descendants_and_ancestors() -> None:
|
||||
entries = [
|
||||
entry("top", "directory"),
|
||||
entry("top/keep", "directory"),
|
||||
entry("top/keep/file.txt", "file"),
|
||||
entry("top/drop.txt", "file"),
|
||||
]
|
||||
|
||||
selected = snapshot._select_restore_entries(entries, ["top/keep"])
|
||||
|
||||
assert [item["path"] for item in selected] == [
|
||||
"top",
|
||||
"top/keep",
|
||||
"top/keep/file.txt",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("selection", [["../escape"], ["/absolute"], ["a\\b"], [""]])
|
||||
def test_restore_selection_rejects_unsafe_paths(selection: list[str]) -> None:
|
||||
with pytest.raises(snapshot.SnapshotError):
|
||||
snapshot._select_restore_entries([entry("safe", "file")], selection)
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from backup_tool.retention import RetentionError, RetentionPolicy, retained_ids
|
||||
|
||||
|
||||
@dataclass
|
||||
class Backup:
|
||||
id: str
|
||||
created_at: datetime
|
||||
pinned: bool = False
|
||||
tombstoned_at: datetime | None = None
|
||||
|
||||
|
||||
def test_retention_is_union_and_protects_newest() -> None:
|
||||
now = datetime(2026, 7, 29, tzinfo=UTC)
|
||||
backups = [Backup(str(index), now - timedelta(days=index)) for index in range(6)]
|
||||
backups[-1].pinned = True
|
||||
|
||||
kept = retained_ids(backups, RetentionPolicy(keep_last=2, keep_daily=3), now)
|
||||
|
||||
assert {"0", "1", "2", "5"} <= kept
|
||||
|
||||
|
||||
def test_newest_is_kept_even_when_policy_is_zero() -> None:
|
||||
now = datetime(2026, 7, 29, tzinfo=UTC)
|
||||
assert retained_ids([Backup("new", now)], RetentionPolicy(keep_last=0), now) == {"new"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", [{"keep_last": -1}, {"unknown": 1}, {"keep_days": True}])
|
||||
def test_invalid_retention_policy_is_rejected(policy: dict[str, object]) -> None:
|
||||
with pytest.raises(RetentionError):
|
||||
RetentionPolicy.from_dict(policy)
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
import pytest
|
||||
from backup_tool.adapters import SourceError
|
||||
from backup_tool.ssh_adapter import SSHAdapter, load_private_key
|
||||
from backup_tool.ssh_source import SSHSourcePublicConfig
|
||||
|
||||
from tests.conftest import make_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attributes:
|
||||
filename: str
|
||||
st_mode: int
|
||||
st_size: int = 0
|
||||
st_mtime: int = 1
|
||||
|
||||
|
||||
class ServerKey:
|
||||
def __init__(self, algorithm: str = "ssh-ed25519", encoded: str = "AQID") -> None:
|
||||
self.algorithm = algorithm
|
||||
self.encoded = encoded
|
||||
|
||||
def get_name(self) -> str:
|
||||
return self.algorithm
|
||||
|
||||
def get_base64(self) -> str:
|
||||
return self.encoded
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(self, key: ServerKey) -> None:
|
||||
self.key = key
|
||||
self.events: list[str] = []
|
||||
self.closed = False
|
||||
|
||||
def start_client(self, *, timeout: float) -> None:
|
||||
self.events.append("start")
|
||||
|
||||
def get_remote_server_key(self) -> ServerKey:
|
||||
self.events.append("host_key")
|
||||
return self.key
|
||||
|
||||
def auth_publickey(self, username: str, private_key: object) -> None:
|
||||
self.events.append("auth")
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class Channel:
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
|
||||
def settimeout(self, timeout: float) -> None:
|
||||
self.events.append("timeout")
|
||||
|
||||
|
||||
class Handle:
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self.chunks = chunks
|
||||
self.closed = False
|
||||
|
||||
def read(self, size: int) -> bytes:
|
||||
assert size == 4096
|
||||
return self.chunks.pop(0) if self.chunks else b""
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class SFTP:
|
||||
def __init__(self, events: list[str], entries: list[Attributes]) -> None:
|
||||
self.events = events
|
||||
self.entries = entries
|
||||
self.handle = Handle([b"one", b"two"])
|
||||
self.closed = False
|
||||
|
||||
def get_channel(self) -> Channel:
|
||||
return Channel(self.events)
|
||||
|
||||
def listdir_iter(self, path: str, *, read_aheads: int):
|
||||
self.events.append(f"list:{path}:{read_aheads}")
|
||||
return iter(self.entries)
|
||||
|
||||
def lstat(self, path: str) -> Attributes:
|
||||
self.events.append(f"lstat:{path}")
|
||||
return Attributes("file", stat.S_IFREG | 0o640, 6, 1)
|
||||
|
||||
def open(self, path: str, mode: str, bufsize: int) -> Handle:
|
||||
self.events.append(f"open:{path}:{mode}:{bufsize}")
|
||||
return self.handle
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def config() -> SSHSourcePublicConfig:
|
||||
return SSHSourcePublicConfig(
|
||||
hostname="backup.example.test",
|
||||
port=22,
|
||||
username="backup",
|
||||
host_key="ssh-ed25519 AQID",
|
||||
root="/",
|
||||
)
|
||||
|
||||
|
||||
def adapter(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, transport: Transport, sftp: SFTP
|
||||
) -> SSHAdapter:
|
||||
monkeypatch.setattr("backup_tool.ssh_adapter.load_private_key", lambda _: object())
|
||||
settings = make_settings(tmp_path).model_copy(update={"ssh_read_chunk_bytes": 4096})
|
||||
return SSHAdapter(
|
||||
config(),
|
||||
"private-key-is-never-sent-to-a-log",
|
||||
settings,
|
||||
transport_factory=lambda *_: transport,
|
||||
sftp_factory=lambda _: sftp,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_pin_mismatch_never_authenticates_or_opens_sftp(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
transport = Transport(ServerKey(encoded="BAUG"))
|
||||
sftp = SFTP(transport.events, [])
|
||||
reader = adapter(tmp_path, monkeypatch, transport, sftp)
|
||||
|
||||
with pytest.raises(SourceError, match="host key") as error:
|
||||
await reader.probe()
|
||||
|
||||
assert error.value.reason_code == "source_trust"
|
||||
assert transport.events == ["start", "host_key"]
|
||||
assert transport.closed
|
||||
assert "timeout" not in sftp.events
|
||||
assert not any(event.startswith("list:") for event in sftp.events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pinned_transport_authenticates_before_sftp_and_streams_bounded_reads(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
transport = Transport(ServerKey())
|
||||
sftp = SFTP(transport.events, [Attributes("file", stat.S_IFREG | 0o640, 6, 1)])
|
||||
reader = adapter(tmp_path, monkeypatch, transport, sftp)
|
||||
|
||||
entries = [entry async for entry in reader.enumerate_entries()]
|
||||
content = b"".join([chunk async for chunk in reader.open_content("file")])
|
||||
await reader.close()
|
||||
|
||||
assert entries[0].path == "file"
|
||||
assert content == b"onetwo"
|
||||
assert transport.events.index("host_key") < transport.events.index("auth")
|
||||
assert transport.events.index("auth") < transport.events.index("timeout")
|
||||
assert "list:/:32" in sftp.events
|
||||
assert "open:/file:rb:4096" in sftp.events
|
||||
assert sftp.handle.closed and sftp.closed and transport.closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mode", [stat.S_IFLNK | 0o777, stat.S_IFIFO | 0o600])
|
||||
async def test_sftp_rejects_symlinks_and_special_entries(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: int
|
||||
) -> None:
|
||||
transport = Transport(ServerKey())
|
||||
sftp = SFTP(transport.events, [Attributes("unsafe", mode)])
|
||||
reader = adapter(tmp_path, monkeypatch, transport, sftp)
|
||||
|
||||
with pytest.raises(SourceError, match="symlink|unsupported"):
|
||||
await anext(reader.enumerate_entries())
|
||||
|
||||
assert "auth" in transport.events
|
||||
await reader.close()
|
||||
|
||||
|
||||
def test_private_key_loader_rejects_short_rsa_and_accepts_strong_rsa() -> None:
|
||||
short = paramiko.RSAKey.generate(2048)
|
||||
strong = paramiko.RSAKey.generate(3072)
|
||||
short_buffer = io.StringIO()
|
||||
strong_buffer = io.StringIO()
|
||||
short.write_private_key(short_buffer)
|
||||
strong.write_private_key(strong_buffer)
|
||||
|
||||
with pytest.raises(SourceError, match="algorithm") as error:
|
||||
load_private_key(short_buffer.getvalue())
|
||||
assert error.value.reason_code == "source_auth"
|
||||
loaded = load_private_key(strong_buffer.getvalue())
|
||||
assert isinstance(loaded, paramiko.RSAKey)
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from backup_tool.ssh_source import SSHSourcePublicConfig
|
||||
from pydantic import ValidationError
|
||||
|
||||
VALID_CONFIG = {
|
||||
"hostname": "backup.example.test",
|
||||
"port": 22,
|
||||
"username": "backup",
|
||||
"host_key": "ssh-ed25519 AQID",
|
||||
"root": "/",
|
||||
}
|
||||
|
||||
|
||||
def test_ssh_source_config_is_closed_and_canonical() -> None:
|
||||
config = SSHSourcePublicConfig.model_validate(VALID_CONFIG)
|
||||
|
||||
assert config.model_dump() == VALID_CONFIG
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("hostname", "backup user@example.test"),
|
||||
("hostname", "ssh://backup.example.test"),
|
||||
("port", 0),
|
||||
("port", 65536),
|
||||
("username", "backup user"),
|
||||
("username", "backup/root"),
|
||||
("host_key", "ssh-ed25519 not-base64!"),
|
||||
("host_key", "ssh-rsa AQID"),
|
||||
("root", "/data"),
|
||||
],
|
||||
)
|
||||
def test_ssh_source_config_rejects_invalid_public_values(field: str, value: object) -> None:
|
||||
invalid = {**VALID_CONFIG, field: value}
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
SSHSourcePublicConfig.model_validate(invalid)
|
||||
|
||||
|
||||
def test_ssh_source_config_rejects_non_public_connection_options() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SSHSourcePublicConfig.model_validate({**VALID_CONFIG, "password": "not-allowed"})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
SSHSourcePublicConfig.model_validate({**VALID_CONFIG, "remote_command": "not-allowed"})
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from backup_tool.notifications.webhook import (
|
||||
SigningMaterial,
|
||||
canonical_signing_input,
|
||||
signatures,
|
||||
webhook_headers,
|
||||
)
|
||||
|
||||
|
||||
def test_signature_is_stable_and_dual_key_versioned() -> None:
|
||||
body = b'{"id":"018f"}'
|
||||
timestamp = "2026-07-30T00:00:00+00:00"
|
||||
keys = [
|
||||
SigningMaterial("key-a", 1, "old-secret"),
|
||||
SigningMaterial("key-b", 2, "new-secret"),
|
||||
]
|
||||
values = signatures(timestamp, body, keys)
|
||||
assert len(values) == 2
|
||||
assert "key_id=key-a" in values[0] and "key_version=1" in values[0]
|
||||
assert "key_id=key-b" in values[1] and "key_version=2" in values[1]
|
||||
assert values == signatures(timestamp, body, keys)
|
||||
assert canonical_signing_input(timestamp, body).endswith(body)
|
||||
headers = webhook_headers("event-id", "execution.queued", timestamp, body, keys)
|
||||
receiver_headers = {key: value for key, value in headers if key != "X-Backup-Signature"}
|
||||
receiver_signatures = [value for key, value in headers if key == "X-Backup-Signature"]
|
||||
assert receiver_headers["X-Backup-Event-ID"] == "event-id"
|
||||
assert receiver_headers["X-Backup-Event-Type"] == "execution.queued"
|
||||
assert receiver_headers["X-Backup-Timestamp"] == timestamp
|
||||
assert receiver_signatures == values
|
||||
Reference in New Issue
Block a user