CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 36s
services/backup_s3.py is FabledSteward's SigV4 client ported (put + list, never delete, so an append-only key is enough), with its tests against AWS's published signatures. services/backup_destination.py holds the admin config (backup_config JSON) and delivers a dated tar to a mounted folder or a bucket, and checks either without sending a backup. services/secrets.py seals the S3 secret with Fernet under a key derived from SECRET_KEY; backup._SETTINGS_NOT_EXPORTED keeps it out of every backup. cryptography becomes a direct dependency (already in the image through pyjwt). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
"""The backup destination (services/backup_destination.py) and the sealing it
|
|
keeps the S3 secret under (services/secrets.py).
|
|
|
|
No database: the admin settings are patched, the folder is a tmp_path, and the
|
|
bucket is an httpx.MockTransport. What these pin is the contract a person
|
|
meets in Settings — a config that is incomplete says what is missing, a place
|
|
that can't take a backup says why, and the secret never leaves sealed.
|
|
"""
|
|
import asyncio
|
|
import io
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from scribe.services import backup, backup_destination, secrets
|
|
from scribe.services.backup_destination import (
|
|
BackupConfig, DestinationError, check_destination, deliver,
|
|
)
|
|
from scribe.services.backup_file import read_archive
|
|
|
|
AT = datetime(2026, 10, 15, 2, 0, 0, tzinfo=timezone.utc)
|
|
PAYLOAD = {
|
|
"version": 25, "scope": "full", "exported_at": AT.isoformat(),
|
|
"users": [{"id": 1, "username": "ada"}], "notes": [],
|
|
}
|
|
S3 = BackupConfig(
|
|
destination="s3", s3_endpoint="http://archives:8081", s3_bucket="scribe",
|
|
s3_access_key="AKIA", s3_region="us-east-1",
|
|
)
|
|
|
|
|
|
# ── Sealing ──────────────────────────────────────────────────────────────────
|
|
|
|
def test_a_sealed_secret_opens_and_is_not_the_plain_text():
|
|
token = secrets.seal("wJalrXUtnFEMI/K7MDENG")
|
|
assert "wJalrXUtnFEMI" not in token
|
|
assert secrets.unseal(token) == "wJalrXUtnFEMI/K7MDENG"
|
|
|
|
|
|
def test_another_secret_key_cannot_open_it():
|
|
token = secrets.seal("s3cret")
|
|
with patch.object(secrets.Config, "SECRET_KEY", "a-different-instance"):
|
|
assert secrets.unseal(token) is None
|
|
|
|
|
|
def test_unseal_of_nothing_or_garbage_is_none():
|
|
assert secrets.unseal("") is None
|
|
assert secrets.unseal("not a fernet token") is None
|
|
|
|
|
|
def test_the_secret_setting_never_reaches_a_backup():
|
|
rows = [SimpleNamespace(user_id=1, key=k, value="v") for k in (
|
|
"backup_config", backup_destination.SECRET_SETTING, "theme",
|
|
)]
|
|
keys = [r["key"] for r in backup._setting_rows(rows)]
|
|
assert keys == ["backup_config", "theme"]
|
|
|
|
|
|
def test_load_secret_tells_a_missing_secret_from_one_it_cannot_open():
|
|
async def stored(value):
|
|
async def get(key, default=""):
|
|
return value
|
|
with patch.object(backup_destination, "get_admin_setting", get):
|
|
return await backup_destination.load_secret()
|
|
|
|
assert asyncio.run(stored("")) == (None, False)
|
|
assert asyncio.run(stored(secrets.seal("k"))) == ("k", True)
|
|
assert asyncio.run(stored("sealed-elsewhere")) == (None, True)
|
|
|
|
|
|
# ── Config ───────────────────────────────────────────────────────────────────
|
|
|
|
def test_config_round_trips_and_defaults_to_off():
|
|
assert BackupConfig().enabled is False
|
|
assert BackupConfig.from_dict(S3.to_dict()) == S3
|
|
|
|
|
|
def test_a_damaged_config_still_loads():
|
|
config = BackupConfig.from_dict({
|
|
"hour": "25", "destination": "ftp", "enabled": "yes", "unknown": 1,
|
|
"s3_bucket": " scribe ",
|
|
})
|
|
assert config.hour == backup_destination.DEFAULT_HOUR
|
|
assert config.destination == "folder"
|
|
assert config.enabled is False
|
|
assert config.s3_bucket == "scribe"
|
|
|
|
|
|
def test_an_incomplete_s3_config_names_what_is_missing():
|
|
said = BackupConfig(destination="s3").problems(has_secret=False)
|
|
assert said == [
|
|
"The S3 address must start with http:// or https://.",
|
|
"The bucket name is missing.",
|
|
"The access key is missing.",
|
|
"The secret key is missing.",
|
|
]
|
|
assert S3.problems(has_secret=True) == []
|
|
|
|
|
|
def test_a_relative_folder_is_refused():
|
|
assert BackupConfig(folder_path="backups").problems(has_secret=False)
|
|
|
|
|
|
# ── Folder ───────────────────────────────────────────────────────────────────
|
|
|
|
def test_deliver_to_a_folder_adds_one_dated_file(tmp_path):
|
|
config = BackupConfig(folder_path=str(tmp_path))
|
|
got = asyncio.run(deliver(PAYLOAD, config, None, AT, app_version="v"))
|
|
assert got.name == "scribe-20261015T020000Z.tar"
|
|
assert [p.name for p in tmp_path.iterdir()] == [got.name]
|
|
assert got.size_bytes == (tmp_path / got.name).stat().st_size
|
|
with open(tmp_path / got.name, "rb") as f:
|
|
assert read_archive(f) == PAYLOAD
|
|
|
|
|
|
def test_a_folder_that_isnt_mounted_says_so(tmp_path):
|
|
config = BackupConfig(folder_path=str(tmp_path / "not-mounted"))
|
|
with pytest.raises(DestinationError, match="isn't there"):
|
|
asyncio.run(deliver(PAYLOAD, config, None, AT, app_version="v"))
|
|
|
|
|
|
def test_checking_a_folder_leaves_nothing_behind(tmp_path):
|
|
(tmp_path / "scribe-20261014T020000Z.tar").write_bytes(b"x")
|
|
config = BackupConfig(folder_path=str(tmp_path))
|
|
said = asyncio.run(check_destination(config, None))
|
|
assert "holds 1 Scribe backup" in said
|
|
assert [p.name for p in tmp_path.iterdir()] == ["scribe-20261014T020000Z.tar"]
|
|
|
|
|
|
# ── S3 ───────────────────────────────────────────────────────────────────────
|
|
|
|
def test_deliver_to_s3_puts_the_dated_file_and_keeps_no_copy():
|
|
seen = {}
|
|
|
|
def handler(request: httpx.Request):
|
|
seen["method"], seen["url"] = request.method, str(request.url)
|
|
seen["body"] = request.read()
|
|
return httpx.Response(200)
|
|
|
|
got = asyncio.run(deliver(PAYLOAD, S3, "secret", AT, app_version="v",
|
|
transport=httpx.MockTransport(handler)))
|
|
assert seen["method"] == "PUT"
|
|
assert seen["url"] == "http://archives:8081/scribe/scribe-20261015T020000Z.tar"
|
|
assert read_archive(io.BytesIO(seen["body"])) == PAYLOAD
|
|
assert got.size_bytes == len(seen["body"])
|
|
|
|
|
|
def test_a_refused_upload_is_a_destination_error_in_words():
|
|
body = b"<Error><Code>AccessDenied</Code><Message>no</Message></Error>"
|
|
transport = httpx.MockTransport(lambda r: httpx.Response(403, content=body))
|
|
with pytest.raises(DestinationError, match="the key isn't allowed to do that"):
|
|
asyncio.run(deliver(PAYLOAD, S3, "secret", AT, app_version="v", transport=transport))
|
|
|
|
|
|
def test_s3_without_its_secret_sends_nothing():
|
|
def handler(request):
|
|
raise AssertionError("nothing should be sent")
|
|
|
|
with pytest.raises(DestinationError, match="secret key is missing"):
|
|
asyncio.run(deliver(PAYLOAD, S3, None, AT, app_version="v",
|
|
transport=httpx.MockTransport(handler)))
|
|
|
|
|
|
def test_checking_s3_only_lists():
|
|
xml = (b'<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
|
|
b"<Contents><Key>scribe-20261014T020000Z.tar</Key><Size>9</Size>"
|
|
b"<LastModified>x</LastModified></Contents></ListBucketResult>")
|
|
methods = []
|
|
|
|
def handler(request):
|
|
methods.append(request.method)
|
|
return httpx.Response(200, content=xml)
|
|
|
|
said = asyncio.run(check_destination(S3, "secret", transport=httpx.MockTransport(handler)))
|
|
assert methods == ["GET"]
|
|
assert "holds 1 Scribe backup" in said
|