from contextlib import redirect_stdout
import importlib.util
from io import BytesIO, StringIO
import json
from pathlib import Path
import sys
import tempfile
import threading
import unittest
from typing import Any
from unittest import mock
MODULE_PATH = Path(__file__).resolve().parents[1] / "static_site.py"
SPEC = importlib.util.spec_from_file_location("static_site_deploy", MODULE_PATH)
assert SPEC and SPEC.loader
static_site = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = static_site
SPEC.loader.exec_module(static_site)
DeployError = static_site.DeployError
apply_bunny_plan = static_site.apply_bunny_plan
bunny_plan = static_site.bunny_plan
collect_files = static_site.collect_files
manifest_for = static_site.manifest_for
parse_manifest = static_site.parse_manifest
purge_bunny_gateway = static_site.purge_bunny_gateway
run_concurrently = static_site.run_concurrently
def write_site(root: Path) -> None:
(root / "assets").mkdir()
(root / "index.html").write_text("
Hello
")
(root / "assets" / "app.js").write_text("console.log('hello')")
class StaticSiteTests(unittest.TestCase):
def test_collect_files_requires_index(self) -> None:
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(DeployError, "index.html"):
collect_files(Path(directory))
def test_collect_files_rejects_symlinks(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
(root / "linked").symlink_to(root / "index.html")
with self.assertRaisesRegex(DeployError, "symbolic links"):
collect_files(root)
def test_manifest_round_trip(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
files = collect_files(root)
manifest = manifest_for(files)
parsed = parse_manifest(json.dumps(manifest).encode())
self.assertEqual(
parsed["index.html"]["sha256"], files["index.html"].sha256
)
def test_manifest_rejects_unsafe_remote_path(self) -> None:
raw = b'{"version": 1, "files": {"../other-site/index.html": {}}}'
with self.assertRaisesRegex(DeployError, "unsafe path"):
parse_manifest(raw)
def test_bunny_plan_uploads_assets_before_html_and_removes_stale(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
files = collect_files(root)
remote = {
"index.html": {"sha256": "old", "size": 1},
"assets/app.js": {"sha256": "old", "size": 1},
"old.css": {"sha256": "old", "size": 1},
}
upload, delete = bunny_plan(files, remote)
self.assertEqual(upload, ["assets/app.js", "index.html"])
self.assertEqual(delete, ["old.css"])
def test_bunny_plan_skips_unchanged_files(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
files = collect_files(root)
remote = manifest_for(files)["files"]
self.assertEqual(bunny_plan(files, remote), ([], []))
def test_bunny_transfers_use_multiple_workers(self) -> None:
barrier = threading.Barrier(4)
completed: list[int] = []
def transfer(item: int) -> None:
barrier.wait(timeout=2)
completed.append(item)
run_concurrently([1, 2, 3, 4], transfer, workers=4)
self.assertCountEqual(completed, [1, 2, 3, 4])
def test_bunny_transfer_order_keeps_documents_and_manifest_safe(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
(root / "about.html").write_text("About
")
files = collect_files(root)
events: list[tuple[str, str]] = []
storage = mock.Mock()
storage.upload.side_effect = lambda item: events.append(
("upload", item.path)
)
storage.delete.side_effect = lambda path: events.append(("delete", path))
storage.upload_manifest.side_effect = lambda _manifest: events.append(
("manifest", static_site.MANIFEST_NAME)
)
apply_bunny_plan(
storage,
files,
["assets/app.js", "about.html", "index.html"],
["stale.css"],
workers=2,
)
asset_position = events.index(("upload", "assets/app.js"))
document_positions = {
events.index(("upload", "about.html")),
events.index(("upload", "index.html")),
}
delete_position = events.index(("delete", "stale.css"))
self.assertTrue(
all(asset_position < position for position in document_positions)
)
self.assertTrue(
all(position < delete_position for position in document_positions)
)
self.assertEqual(events[-1], ("manifest", static_site.MANIFEST_NAME))
def test_gateway_requires_a_plain_https_origin(self) -> None:
invalid = (
"http://purge.example",
"https://purge.example/path",
"https://user@purge.example",
"https://purge.example?token=secret",
)
for endpoint in invalid:
with self.subTest(endpoint=endpoint):
with self.assertRaisesRegex(DeployError, "HTTPS origin"):
purge_bunny_gateway(endpoint, "example-org", "password")
@mock.patch.object(static_site.time, "sleep")
@mock.patch.object(static_site.urllib.request, "urlopen")
def test_storage_retries_transient_new_zone_authentication(
self, urlopen: mock.Mock, sleep: mock.Mock
) -> None:
unauthorized = static_site.urllib.error.HTTPError(
"https://storage.example/example/.static-site-manifest.json",
401,
"Unauthorized",
{},
BytesIO(b'{"HttpCode":401,"Message":"Unauthorized"}'),
)
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b"manifest"
urlopen.side_effect = [unauthorized, response]
storage = static_site.BunnyStorage(
"example", "new-password", "storage.example"
)
self.assertEqual(storage.get_manifest(), b"manifest")
sleep.assert_called_once_with(1)
self.assertEqual(urlopen.call_count, 2)
@mock.patch.object(static_site.time, "sleep")
@mock.patch.object(static_site.urllib.request, "urlopen")
def test_storage_retries_transient_connection_errors(
self, urlopen: mock.Mock, sleep: mock.Mock
) -> None:
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b"manifest"
urlopen.side_effect = [
static_site.urllib.error.URLError("connection reset"),
response,
]
storage = static_site.BunnyStorage(
"example", "storage-password", "storage.example"
)
self.assertEqual(storage.get_manifest(), b"manifest")
sleep.assert_called_once_with(1)
self.assertEqual(urlopen.call_count, 2)
@mock.patch.object(static_site.urllib.request, "urlopen")
def test_gateway_reuses_storage_password(self, urlopen: mock.Mock) -> None:
urlopen.return_value.__enter__.return_value = object()
purge_bunny_gateway(
"https://purge.example", "example-org", "storage-password"
)
request = urlopen.call_args.args[0]
self.assertEqual(
request.full_url,
"https://purge.example/v1/static-sites/example-org/purge",
)
self.assertEqual(request.method, "POST")
self.assertEqual(
request.get_header("Authorization"), "Bearer storage-password"
)
@mock.patch.object(static_site, "finalize_bunny_deployment")
@mock.patch.object(static_site, "start_bunny_deployment")
@mock.patch.object(static_site, "BunnyStorage")
def test_unchanged_deployment_is_finalized_without_forcing_a_purge(
self,
storage_class: mock.Mock,
start_deployment: mock.Mock,
finalize_deployment: mock.Mock,
) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
files = collect_files(root)
storage_class.return_value.get_manifest.return_value = json.dumps(
manifest_for(files)
).encode()
start_deployment.return_value = {"deployment_id": "deployment-id"}
finalize_deployment.return_value = {
"deployment_id": "deployment-id",
"status": "unchanged",
"purged": False,
}
args = static_site.argparse.Namespace(
directory=root,
zone="example-org",
password="storage-password",
endpoint="storage.bunnycdn.com",
purge_endpoint="https://purge.example",
pull_zone_id="",
api_key="",
dry_run=False,
workers=4,
revision="abc1234",
idempotency_key="test-run-123",
)
result = static_site.deploy_bunny(args)
self.assertFalse(result["changed"])
self.assertFalse(result["purged"])
start_deployment.assert_called_once()
finalize_deployment.assert_called_once()
storage_class.return_value.upload_manifest.assert_not_called()
@mock.patch.object(static_site, "abort_bunny_deployment")
@mock.patch.object(static_site, "finalize_bunny_deployment")
@mock.patch.object(static_site, "start_bunny_deployment")
@mock.patch.object(static_site, "apply_bunny_plan")
@mock.patch.object(static_site, "BunnyStorage")
def test_failed_upload_releases_the_deployment_lease(
self,
storage_class: mock.Mock,
apply_plan: mock.Mock,
start_deployment: mock.Mock,
finalize_deployment: mock.Mock,
abort_deployment: mock.Mock,
) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
storage_class.return_value.get_manifest.return_value = None
start_deployment.return_value = {"deployment_id": "deployment-id"}
apply_plan.side_effect = static_site.DeployError("upload failed")
args = static_site.argparse.Namespace(
directory=root,
zone="example-org",
password="storage-password",
endpoint="storage.bunnycdn.com",
purge_endpoint="https://purge.example",
pull_zone_id="",
api_key="",
dry_run=False,
workers=4,
revision="abc1234",
idempotency_key="test-run-123",
)
with self.assertRaisesRegex(static_site.DeployError, "upload failed"):
static_site.deploy_bunny(args)
finalize_deployment.assert_not_called()
abort_deployment.assert_called_once_with(
"https://purge.example",
"example-org",
"storage-password",
"deployment-id",
"test-run-123",
)
MARKDOWN_RULES = '''
[[edge_rules]]
name = "markdown"
url = "*.md"
response_headers = { "Content-Type" = "text/markdown; charset=utf-8" }
[[edge_rules]]
name = "old-blog"
url = ["*/blog/*"]
redirect = { to = "https://example.org{{path}}", status = 301 }
'''
SITE_SECTION = '''
[site]
not_found = "/404.html"
'''
def gateway_args(root: Path, edge_rules: Path | None) -> Any:
return static_site.argparse.Namespace(
directory=root,
zone="example-org",
password="storage-password",
endpoint="storage.bunnycdn.com",
purge_endpoint="https://purge.example",
pull_zone_id="",
api_key="",
dry_run=False,
workers=4,
revision="abc1234",
idempotency_key="",
edge_rules=edge_rules,
)
class EdgeRuleTests(unittest.TestCase):
def write_rules(self, directory: str, text: str) -> Path:
path = Path(directory) / "bunny.toml"
path.write_text(text)
return path
def test_loads_declared_rules(self) -> None:
with tempfile.TemporaryDirectory() as directory:
rules, site = static_site.load_site_config(
self.write_rules(directory, MARKDOWN_RULES + SITE_SECTION)
)
self.assertEqual(site, {"not_found": "/404.html"})
self.assertEqual(
rules,
[
{
"name": "markdown",
"url": "*.md",
"response_headers": {
"Content-Type": "text/markdown; charset=utf-8"
},
},
{
"name": "old-blog",
"url": ["*/blog/*"],
"redirect": {"to": "https://example.org{{path}}", "status": 301},
},
],
)
def test_rejects_malformed_files(self) -> None:
# Rule contents are validated by the gateway; see handler_test.ts.
cases = {
"unknown keys": 'headers = "x"\n' + MARKDOWN_RULES,
"must be a \\[site\\] table": 'site = "spa"\n',
"must be \\[\\[edge_rules\\]\\] tables": 'edge_rules = ["x"]\n',
"unsupported value": MARKDOWN_RULES + "since = 2026-10-02\n",
"not valid TOML": "[[edge_rules]\n",
}
for message, text in cases.items():
with self.subTest(message), tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(DeployError, message):
static_site.load_site_config(self.write_rules(directory, text))
def test_edge_rules_need_the_gateway(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
args = gateway_args(root, self.write_rules(directory, MARKDOWN_RULES))
args.purge_endpoint = ""
with self.assertRaisesRegex(DeployError, "BUNNY_PURGE_ENDPOINT"):
static_site.deploy_bunny(args)
@mock.patch.object(static_site, "finalize_bunny_deployment")
@mock.patch.object(static_site, "start_bunny_deployment")
@mock.patch.object(static_site, "BunnyStorage")
def test_finalization_sends_declared_rules_or_none(
self,
storage_class: mock.Mock,
start_deployment: mock.Mock,
finalize_deployment: mock.Mock,
) -> None:
storage_class.return_value.get_manifest.return_value = None
start_deployment.return_value = {"deployment_id": "deployment-id"}
finalize_deployment.return_value = {"purged": True}
keys = []
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "dist"
root.mkdir()
write_site(root)
rules = self.write_rules(directory, MARKDOWN_RULES + SITE_SECTION)
cases = (
(rules, ["markdown", "old-blog"], {"not_found": "/404.html"}),
(None, [], {}),
)
for path, expected, site in cases:
result = static_site.deploy_bunny(gateway_args(root, path))
for sent in (
start_deployment.call_args.args[5],
finalize_deployment.call_args.args[7],
):
self.assertEqual([rule["name"] for rule in sent], expected)
# No [site] means the defaults, which reset earlier settings.
self.assertEqual(start_deployment.call_args.args[6], site)
self.assertEqual(finalize_deployment.call_args.args[8], site)
self.assertEqual(result["edge_rules"], expected)
keys.append(start_deployment.call_args.args[3])
# Changing only the rules must not replay the previous deployment.
self.assertNotEqual(keys[0], keys[1])
if __name__ == "__main__":
unittest.main()
def identity_args(root: Path) -> Any:
args = gateway_args(root, None)
args.password = ""
args.idempotency_key = "test-run-identity"
return args
class IdentityTests(unittest.TestCase):
@mock.patch.object(static_site, "finalize_bunny_deployment")
@mock.patch.object(static_site, "start_bunny_deployment")
@mock.patch.object(static_site, "BunnyStorage")
@mock.patch.object(static_site, "request_identity_token")
def test_identity_deploys_with_the_password_the_gateway_hands_out(
self,
request_token: mock.Mock,
storage_class: mock.Mock,
start_deployment: mock.Mock,
finalize_deployment: mock.Mock,
) -> None:
request_token.side_effect = ["jwt-start", "jwt-finalize"]
start_deployment.return_value = {
"deployment_id": "deployment-id",
"storage_password": "zone-password",
"storage_endpoint": "la.storage.bunnycdn.com",
}
storage_class.return_value.get_manifest.return_value = None
finalize_deployment.return_value = {"purged": True}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
output = StringIO()
with redirect_stdout(output), mock.patch.object(static_site, "apply_bunny_plan"):
result = static_site.deploy_bunny(identity_args(root))
request_token.assert_called_with("https://purge.example")
self.assertEqual(start_deployment.call_args.args[2], "jwt-start")
self.assertEqual(finalize_deployment.call_args.args[2], "jwt-finalize")
storage_class.assert_called_once_with(
"example-org", "zone-password", "la.storage.bunnycdn.com"
)
self.assertIn("::add-mask::zone-password", output.getvalue())
self.assertNotIn("zone-password", json.dumps(result))
self.assertTrue(result["purged"])
def test_identity_needs_the_gateway(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
args = identity_args(root)
args.purge_endpoint = ""
with self.assertRaisesRegex(DeployError, "required without the Gandalf gateway"):
static_site.deploy_bunny(args)
@mock.patch.object(static_site, "start_bunny_deployment")
def test_identity_dry_run_stays_local(self, start_deployment: mock.Mock) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
args = identity_args(root)
args.dry_run = True
result = static_site.deploy_bunny(args)
start_deployment.assert_not_called()
self.assertEqual(result["files"], 2)
self.assertIsNone(result["changed"])
def test_identity_token_request_explains_missing_permissions(self) -> None:
with mock.patch.dict(static_site.os.environ, {}, clear=True):
with self.assertRaisesRegex(DeployError, "id-token: write"):
static_site.request_identity_token("https://gandalf.example")
@mock.patch.object(static_site.urllib.request, "urlopen")
def test_identity_token_request_asks_for_the_gateway_audience(
self, urlopen: mock.Mock
) -> None:
urlopen.return_value.__enter__.return_value.read.return_value = (
b'{"count": 1, "value": "jwt"}'
)
environment = {
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://ci.example/token?api-version=2",
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token",
}
with mock.patch.dict(static_site.os.environ, environment, clear=True):
token = static_site.request_identity_token("https://gandalf.example")
self.assertEqual(token, "jwt")
request = urlopen.call_args.args[0]
self.assertEqual(
request.full_url,
"https://ci.example/token?api-version=2&audience=https%3A%2F%2Fgandalf.example",
)
self.assertEqual(request.get_header("Authorization"), "bearer request-token")