init
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from build_api.app import create_application
|
||||
from build_api.service import BuildService, Settings
|
||||
|
||||
|
||||
CHANNEL = "a" * 32
|
||||
TOKEN = "test-secret"
|
||||
|
||||
|
||||
class BuildApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.base = Path(self.temporary.name)
|
||||
self.builder = self.base / "fake_builder.py"
|
||||
self.builder.write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", required=True)
|
||||
parser.add_argument("--channel-id", required=True)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
parser.add_argument("--deployment-domains", action="append", default=[])
|
||||
parser.add_argument("--reporting-domains", action="append", default=[])
|
||||
args = parser.parse_args()
|
||||
counter = pathlib.Path(__file__).with_name("build-count")
|
||||
count = int(counter.read_text()) if counter.exists() else 0
|
||||
counter.write_text(str(count + 1))
|
||||
if "fail.example" in args.deployment_domains:
|
||||
print("deliberate builder failure", file=sys.stderr)
|
||||
raise SystemExit(3)
|
||||
root = pathlib.Path(args.root)
|
||||
if root.exists() and not args.force:
|
||||
raise SystemExit("root exists; pass --force")
|
||||
(root / "web").mkdir(parents=True, exist_ok=True)
|
||||
(root / "web" / "support.html").write_text("ready")
|
||||
(root / "sync").mkdir(exist_ok=True)
|
||||
(root / "sync" / "daily.html").write_text("sync")
|
||||
(root / "out").mkdir(exist_ok=True)
|
||||
(root / "out" / "arguments.json").write_text(
|
||||
__import__("json").dumps(vars(args), sort_keys=True)
|
||||
)
|
||||
(root / "manifest.json").write_text(
|
||||
__import__("json").dumps(
|
||||
{
|
||||
"channel_id": args.channel_id,
|
||||
"support_path": f"/channel/{args.channel_id}/web/support.html",
|
||||
"daily_path": f"/channel/{args.channel_id}/sync/daily.html",
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.builder.chmod(self.builder.stat().st_mode | stat.S_IXUSR)
|
||||
self.service = BuildService(
|
||||
Settings(
|
||||
artifact_root=self.base / "artifacts",
|
||||
bearer_token=TOKEN,
|
||||
project_script=self.builder,
|
||||
python=os.environ.get("PYTHON", os.sys.executable),
|
||||
build_timeout=10,
|
||||
)
|
||||
)
|
||||
self.client = TestClient(
|
||||
create_application(settings=self.service.settings, service=self.service)
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, Any] | bytes | None = None,
|
||||
*,
|
||||
authenticated: bool = True,
|
||||
content_type: str = "application/json",
|
||||
) -> tuple[int, dict[str, Any] | None, dict[str, str]]:
|
||||
headers: dict[str, str] = {}
|
||||
if authenticated:
|
||||
headers["Authorization"] = f"Bearer {TOKEN}"
|
||||
if body is not None or method in {"POST", "PUT", "PATCH"}:
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
if isinstance(body, dict):
|
||||
response = self.client.request(method, path, headers=headers, json=body)
|
||||
elif isinstance(body, bytes):
|
||||
headers.setdefault("Content-Type", content_type)
|
||||
response = self.client.request(method, path, headers=headers, content=body)
|
||||
else:
|
||||
response = self.client.request(method, path, headers=headers)
|
||||
|
||||
payload = response.json() if response.content else None
|
||||
return response.status_code, payload, {
|
||||
key: value for key, value in response.headers.items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def valid_body(**overrides: Any) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"deployment_domains": ["deploy.example"],
|
||||
"reporting_domains": ["report.example"],
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
def build_count(self) -> int:
|
||||
path = self.base / "build-count"
|
||||
return int(path.read_text()) if path.exists() else 0
|
||||
|
||||
def test_health_is_public_and_auth_is_required_elsewhere(self) -> None:
|
||||
status, payload, _ = self.request("GET", "/health", authenticated=False)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual({"status": "ok"}, payload)
|
||||
|
||||
status, payload, _ = self.request(
|
||||
"GET", f"/v1/channels/{CHANNEL}", authenticated=False
|
||||
)
|
||||
self.assertEqual(401, status)
|
||||
self.assertEqual("unauthorized", payload["error"]["code"])
|
||||
|
||||
def test_build_status_and_builder_contract(self) -> None:
|
||||
status, payload, headers = self.request(
|
||||
"POST", f"/v1/channels/{CHANNEL}/build", self.valid_body()
|
||||
)
|
||||
self.assertEqual(201, status)
|
||||
self.assertEqual(f"/v1/channels/{CHANNEL}", headers["location"])
|
||||
self.assertEqual("built", payload["status"])
|
||||
self.assertEqual(64, len(payload["manifest"]["input_digest"]))
|
||||
|
||||
release = self.service.channels / CHANNEL
|
||||
self.assertTrue((release / "web" / "support.html").is_file())
|
||||
self.assertTrue((release / "sync" / "daily.html").is_file())
|
||||
self.assertEqual(payload["support_path"], f"/channel/{CHANNEL}/web/support.html")
|
||||
self.assertEqual(payload["daily_path"], f"/channel/{CHANNEL}/sync/daily.html")
|
||||
arguments = json.loads((release / "out" / "arguments.json").read_text())
|
||||
self.assertEqual(
|
||||
str(release.parent.parent / "staging" / payload["manifest"]["request_id"]),
|
||||
arguments["root"],
|
||||
)
|
||||
self.assertEqual(CHANNEL, arguments["channel_id"])
|
||||
self.assertTrue(arguments["force"])
|
||||
|
||||
status, current, _ = self.request("GET", f"/v1/channels/{CHANNEL}")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(payload["manifest"], current["manifest"])
|
||||
|
||||
def test_identical_build_is_idempotent(self) -> None:
|
||||
first, _, _ = self.request(
|
||||
"POST", f"/v1/channels/{CHANNEL}/build", self.valid_body()
|
||||
)
|
||||
second, payload, _ = self.request(
|
||||
"POST", f"/v1/channels/{CHANNEL}/build", self.valid_body()
|
||||
)
|
||||
self.assertEqual(201, first)
|
||||
self.assertEqual(200, second)
|
||||
self.assertEqual("built", payload["status"])
|
||||
self.assertEqual(1, self.build_count())
|
||||
|
||||
def test_conflict_and_force_rebuild(self) -> None:
|
||||
self.request("POST", f"/v1/channels/{CHANNEL}/build", self.valid_body())
|
||||
changed = self.valid_body(deployment_domains=["changed.example"])
|
||||
status, payload, _ = self.request(
|
||||
"POST", f"/v1/channels/{CHANNEL}/build", changed
|
||||
)
|
||||
self.assertEqual(409, status)
|
||||
self.assertEqual("build_conflict", payload["error"]["code"])
|
||||
|
||||
changed["force"] = True
|
||||
status, payload, _ = self.request(
|
||||
"POST", f"/v1/channels/{CHANNEL}/build", changed
|
||||
)
|
||||
self.assertEqual(201, status)
|
||||
self.assertEqual(["changed.example"], payload["manifest"]["input"]["deployment_domains"])
|
||||
self.assertEqual(2, self.build_count())
|
||||
|
||||
def test_failed_force_build_keeps_old_release(self) -> None:
|
||||
_, original, _ = self.request(
|
||||
"POST", f"/v1/channels/{CHANNEL}/build", self.valid_body()
|
||||
)
|
||||
status, payload, _ = self.request(
|
||||
"POST",
|
||||
f"/v1/channels/{CHANNEL}/build",
|
||||
self.valid_body(deployment_domains=["fail.example"], force=True),
|
||||
)
|
||||
self.assertEqual(500, status)
|
||||
self.assertEqual("build_failed", payload["error"]["code"])
|
||||
|
||||
status, current, _ = self.request("GET", f"/v1/channels/{CHANNEL}")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(original["manifest"], current["manifest"])
|
||||
self.assertEqual([], list(self.service.staging.iterdir()))
|
||||
|
||||
def test_nonblocking_channel_lock_returns_conflict(self) -> None:
|
||||
with self.service._channel_lock(CHANNEL):
|
||||
status, payload, _ = self.request(
|
||||
"POST", f"/v1/channels/{CHANNEL}/build", self.valid_body()
|
||||
)
|
||||
self.assertEqual(409, status)
|
||||
self.assertEqual("channel_locked", payload["error"]["code"])
|
||||
|
||||
def test_delete_is_authenticated_and_idempotent(self) -> None:
|
||||
self.request("POST", f"/v1/channels/{CHANNEL}/build", self.valid_body())
|
||||
first, payload, _ = self.request("DELETE", f"/v1/channels/{CHANNEL}")
|
||||
second, _, _ = self.request("DELETE", f"/v1/channels/{CHANNEL}")
|
||||
self.assertEqual((204, None), (first, payload))
|
||||
self.assertEqual(204, second)
|
||||
self.assertFalse((self.service.channels / CHANNEL).exists())
|
||||
|
||||
def test_delete_rejects_symlink_release(self) -> None:
|
||||
outside = self.base / "outside"
|
||||
outside.mkdir()
|
||||
(self.service.channels / CHANNEL).symlink_to(outside, target_is_directory=True)
|
||||
status, payload, _ = self.request("DELETE", f"/v1/channels/{CHANNEL}")
|
||||
self.assertEqual(409, status)
|
||||
self.assertEqual("unsafe_release", payload["error"]["code"])
|
||||
self.assertTrue(outside.exists())
|
||||
|
||||
def test_validation_errors_are_422(self) -> None:
|
||||
cases = [
|
||||
(f"/v1/channels/{'A' * 32}/build", self.valid_body()),
|
||||
(f"/v1/channels/{CHANNEL}/build", {}),
|
||||
(
|
||||
f"/v1/channels/{CHANNEL}/build",
|
||||
self.valid_body(deployment_domains=["https://bad.example/path"]),
|
||||
),
|
||||
(
|
||||
f"/v1/channels/{CHANNEL}/build",
|
||||
self.valid_body(force="yes"),
|
||||
),
|
||||
(
|
||||
f"/v1/channels/{CHANNEL}/build",
|
||||
self.valid_body(extra=True),
|
||||
),
|
||||
]
|
||||
for path, body in cases:
|
||||
with self.subTest(path=path, body=body):
|
||||
status, payload, _ = self.request("POST", path, body)
|
||||
self.assertEqual(422, status)
|
||||
self.assertEqual("validation_error", payload["error"]["code"])
|
||||
|
||||
def test_malformed_json_is_422(self) -> None:
|
||||
status, payload, _ = self.request(
|
||||
"POST", f"/v1/channels/{CHANNEL}/build", b"{"
|
||||
)
|
||||
self.assertEqual(422, status)
|
||||
self.assertEqual("validation_error", payload["error"]["code"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user