"""The backup destination's S3 client (services/backup_s3.py): signatures match
AWS's published Signature Version 4 examples, and the calls send and read what
S3 expects, over a real HTTP connection to a local stand-in server."""
import socket
import threading
from datetime import UTC, datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from backend.app.services import backup_s3 as s3
# AWS's example credentials and moment ("Signature Calculations for the
# Authorization Header", Amazon S3 API reference).
KEY_ID = "AKIAIOSFODNN7EXAMPLE"
SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
AT = datetime(2013, 5, 24, tzinfo=UTC)
def _signature(headers):
return headers["Authorization"].rsplit("Signature=", 1)[1]
def test_aws_example_get_object():
h = s3.sign("GET", "https://examplebucket.s3.amazonaws.com/test.txt",
{"Range": "bytes=0-9"}, s3.EMPTY_SHA256, KEY_ID, SECRET, "us-east-1", AT)
assert "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date," in h["Authorization"]
assert "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request" in h[
"Authorization"]
assert _signature(h) == "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
def test_aws_example_list_objects():
h = s3.sign("GET", "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", {},
s3.EMPTY_SHA256, KEY_ID, SECRET, "us-east-1", AT)
assert _signature(h) == "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7"
def test_a_changed_secret_changes_the_signature():
url = "https://examplebucket.s3.amazonaws.com/test.txt"
a = s3.sign("GET", url, {}, s3.EMPTY_SHA256, KEY_ID, SECRET, "us-east-1", AT)
b = s3.sign("GET", url, {}, s3.EMPTY_SHA256, KEY_ID, SECRET + "x", "us-east-1", AT)
assert _signature(a) != _signature(b)
def test_paths_are_path_style_and_keys_are_escaped():
t = s3.S3Target("http://archives.example:8081/s3", "curator", KEY_ID, SECRET)
assert t.url("media/ab/cd/abcd.jpg") == "http://archives.example:8081/s3/curator/media/ab/cd/abcd.jpg"
assert t.url() == "http://archives.example:8081/s3/curator/"
assert t.url("a b+c.jpg") == "http://archives.example:8081/s3/curator/a%20b%2Bc.jpg"
# ── Calls, against a local stand-in ─────────────────────────────────────────
class _Server:
"""A local HTTP server that records each request and answers with
`reply(handler) -> (status, body)`."""
def __init__(self, reply):
self.seen = []
outer = self
class Handler(BaseHTTPRequestHandler):
def _serve(self):
length = int(self.headers.get("Content-Length") or 0)
outer.seen.append({
"method": self.command, "path": self.path,
"headers": {k.lower(): v for k, v in self.headers.items()},
"body": self.rfile.read(length) if length else b"",
})
status, body = reply(self)
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
do_GET = do_PUT = _serve
def log_message(self, *args):
pass
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.httpd.shutdown()
self.httpd.server_close()
def target(self, bucket="curator"):
host, port = self.httpd.server_address
return s3.S3Target(f"http://{host}:{port}", bucket, KEY_ID, SECRET)
def test_put_streams_the_file_signed(tmp_path):
f = tmp_path / "abcd.jpg"
f.write_bytes(b"x" * 5000)
with _Server(lambda h: (200, b"")) as srv:
s3.put_file(srv.target(), "media/ab/cd/abcd.jpg", f, content_type="image/jpeg")
req = srv.seen[0]
assert req["method"] == "PUT"
assert req["path"] == "/curator/media/ab/cd/abcd.jpg"
assert req["body"] == b"x" * 5000
assert req["headers"]["x-amz-content-sha256"] == s3.file_sha256(f)
assert req["headers"]["content-type"] == "image/jpeg"
assert req["headers"]["authorization"].startswith(
"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")
def test_put_signs_with_a_known_sha_without_rereading(tmp_path, monkeypatch):
f = tmp_path / "b.bin"
f.write_bytes(b"y")
monkeypatch.setattr(s3, "file_sha256", lambda p: pytest.fail("read the file twice"))
with _Server(lambda h: (200, b"")) as srv:
s3.put_file(srv.target(), "b.bin", f, payload_sha256="a" * 64)
assert srv.seen[0]["headers"]["x-amz-content-sha256"] == "a" * 64
def test_a_refusal_says_why(tmp_path):
f = tmp_path / "b.bin"
f.write_bytes(b"x")
body = b"SignatureDoesNotMatch
AccessDenied