CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m38s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m5s
Desktop (Tauri) / Update manifest (push) Successful in 3s
#5918 "Backup file": an uncompressed tar of per-table JSON Lines (primary-key order, sorted keys) plus every attachment file, with fixed tar headers, a dated name, and an atomic write through a dot-named .part file. Unchanged rows are the same bytes every day, so restic off-site stores only the edits. No credential ever enters a backup. #5919 "Backup destinations": a folder, or an S3-compatible bucket through a stdlib SigV4 put/list client (checked against AWS's published signatures), with an upload limit. Test destination only lists, so an append-only key works. #5920 "Backup job": daily at a chosen hour, Back up now, a 1/2/4 h retry ladder, a one-hour deadline, an advisory lock, the last 30 runs, and an overdue verdict on Settings -> Backups. #5921 "Restore": python -m inkwell.restore FILE into an empty instance, an integration test that backs up and restores, and docs/backups.md with Fabled Archives as the worked example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
import threading
|
|
from datetime import datetime, timezone
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
import pytest
|
|
|
|
from inkwell.backup_s3 import EMPTY_SHA256, S3Error, S3Target, list_objects, put_file, sign
|
|
|
|
# AWS's published Signature Version 4 examples for S3 ("Signature Calculations for the
|
|
# Authorization Header: Transferring Payload in a Single Chunk").
|
|
AK = "AKIAIOSFODNN7EXAMPLE"
|
|
SK = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
|
AT = datetime(2013, 5, 24, tzinfo=timezone.utc)
|
|
|
|
|
|
def _signature(headers: dict) -> str:
|
|
return headers["Authorization"].rsplit("Signature=", 1)[1]
|
|
|
|
|
|
def test_get_object_matches_aws_example():
|
|
headers = sign(
|
|
"GET", "https://examplebucket.s3.amazonaws.com/test.txt", {"Range": "bytes=0-9"},
|
|
EMPTY_SHA256, AK, SK, "us-east-1", AT,
|
|
)
|
|
assert "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date" in headers["Authorization"]
|
|
assert _signature(headers) == "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
|
|
|
|
|
|
def test_list_objects_matches_aws_example():
|
|
headers = sign(
|
|
"GET", "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", {},
|
|
EMPTY_SHA256, AK, SK, "us-east-1", AT,
|
|
)
|
|
assert _signature(headers) == "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7"
|
|
|
|
|
|
# ---- against a local stand-in bucket ---------------------------------------------------
|
|
|
|
LISTING = b"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<Contents><Key>inkwell-20261015T020000Z.tar</Key><Size>42</Size><LastModified>2026-10-15T02:00:05Z</LastModified></Contents>
|
|
</ListBucketResult>"""
|
|
|
|
DENIED = b"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>"""
|
|
|
|
|
|
@pytest.fixture
|
|
def bucket():
|
|
received: dict = {"puts": {}, "deny": False}
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def do_PUT(self):
|
|
length = int(self.headers["Content-Length"])
|
|
received["puts"][self.path] = (self.rfile.read(length), dict(self.headers))
|
|
if received["deny"]:
|
|
self.send_response(403)
|
|
self.end_headers()
|
|
self.wfile.write(DENIED)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
def do_GET(self):
|
|
received["get"] = self.path
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", str(len(LISTING)))
|
|
self.end_headers()
|
|
self.wfile.write(LISTING)
|
|
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
received["target"] = S3Target(f"http://127.0.0.1:{server.server_address[1]}", "backups", AK, SK)
|
|
yield received
|
|
server.shutdown()
|
|
|
|
|
|
def test_a_file_is_put_whole_and_signed(bucket, tmp_path):
|
|
path = tmp_path / "inkwell-x.tar"
|
|
path.write_bytes(b"x" * 300_000)
|
|
put_file(bucket["target"], "inkwell-x.tar", path, limit_kbps=100_000)
|
|
body, headers = bucket["puts"]["/backups/inkwell-x.tar"]
|
|
assert body == b"x" * 300_000
|
|
assert headers["Authorization"].startswith(f"AWS4-HMAC-SHA256 Credential={AK}/")
|
|
assert headers["Host"] == bucket["target"].endpoint.split("//", 1)[1]
|
|
|
|
|
|
def test_a_refusal_says_why_in_words(bucket, tmp_path):
|
|
bucket["deny"] = True
|
|
path = tmp_path / "inkwell-x.tar"
|
|
path.write_bytes(b"x")
|
|
with pytest.raises(S3Error, match="AccessDenied: the key isn't allowed to do that"):
|
|
put_file(bucket["target"], "inkwell-x.tar", path)
|
|
|
|
|
|
def test_listing_reads_the_objects(bucket):
|
|
objects = list_objects(bucket["target"], "inkwell-")
|
|
assert [(o.key, o.size) for o in objects] == [("inkwell-20261015T020000Z.tar", 42)]
|
|
assert "list-type=2" in bucket["get"] and "prefix=inkwell-" in bucket["get"]
|
|
|
|
|
|
def test_an_address_nothing_answers_on_says_so():
|
|
target = S3Target("http://127.0.0.1:1", "backups", AK, SK)
|
|
with pytest.raises(S3Error, match="Couldn't reach"):
|
|
list_objects(target)
|