Coverage for backend / tests / test_get_file.py: 100%
57 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 15:51 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 15:51 +0000
1from unittest.mock import AsyncMock
3import pytest
4from botocore.exceptions import ClientError
5from fastapi.testclient import TestClient
7SITE_ID = "aabbccddeeff"
10def _mock_s3_body(mock_s3_client: AsyncMock, content: bytes) -> None:
11 body = AsyncMock()
12 body.read.return_value = content
13 mock_s3_client.get_object.return_value = {"Body": body}
16class TestGetFile:
17 def test_returns_html_content(self, client: TestClient, mock_s3_client: AsyncMock):
18 _mock_s3_body(mock_s3_client, b"<html>hello</html>")
20 resp = client.get(f"/coverage/{SITE_ID}/index.html")
22 assert resp.status_code == 200
23 assert resp.content == b"<html>hello</html>"
24 assert resp.headers["content-type"] == "text/html; charset=utf-8"
25 mock_s3_client.get_object.assert_awaited_once_with(
26 Bucket="test-bucket",
27 Key=f"sites/{SITE_ID}/index.html",
28 )
30 def test_returns_css_content_type(
31 self, client: TestClient, mock_s3_client: AsyncMock
32 ):
33 _mock_s3_body(mock_s3_client, b"body {}")
35 resp = client.get(f"/coverage/{SITE_ID}/style.css")
37 assert resp.status_code == 200
38 assert resp.headers["content-type"] == "text/css; charset=utf-8"
39 assert resp.content == b"body {}"
41 def test_unknown_extension_returns_octet_stream(
42 self, client: TestClient, mock_s3_client: AsyncMock
43 ):
44 _mock_s3_body(mock_s3_client, b"data")
46 resp = client.get(f"/coverage/{SITE_ID}/file.unknownext")
48 assert resp.status_code == 200
49 assert resp.headers["content-type"] == "application/octet-stream"
50 assert resp.content == b"data"
52 def test_missing_file_returns_404(
53 self, client: TestClient, mock_s3_client: AsyncMock
54 ):
55 mock_s3_client.get_object.side_effect = ClientError(
56 error_response={"Error": {"Code": "NoSuchKey"}},
57 operation_name="GetObject",
58 )
60 resp = client.get(f"/coverage/{SITE_ID}/missing.html")
62 assert resp.status_code == 404
64 def test_empty_path_serves_index_html(
65 self, client: TestClient, mock_s3_client: AsyncMock
66 ):
67 _mock_s3_body(mock_s3_client, b"<html>index</html>")
69 client.get(f"/coverage/{SITE_ID}/")
71 mock_s3_client.get_object.assert_awaited_once_with(
72 Bucket="test-bucket",
73 Key=f"sites/{SITE_ID}/index.html",
74 )
76 def test_directory_path_serves_index_html(
77 self, client: TestClient, mock_s3_client: AsyncMock
78 ):
79 _mock_s3_body(mock_s3_client, b"<html>index</html>")
81 client.get(f"/coverage/{SITE_ID}/subdir/")
83 mock_s3_client.get_object.assert_awaited_once_with(
84 Bucket="test-bucket",
85 Key=f"sites/{SITE_ID}/subdir/index.html",
86 )
88 @pytest.mark.parametrize(
89 "site_id",
90 [
91 pytest.param("short", id="too-short"),
92 pytest.param("aabbccddeeff11", id="too-long"),
93 pytest.param("AABBCCDDEEFF", id="uppercase-hex"),
94 pytest.param("xxxxxxxxxxxx", id="non-hex-chars"),
95 ],
96 )
97 def test_invalid_site_id_returns_422(self, client: TestClient, site_id: str):
98 resp = client.get(f"/coverage/{site_id}/index.html")
100 assert resp.status_code == 422
101 resp_json = resp.json()
102 assert resp_json["detail"][0]["loc"] == ["path", "site_id"]
103 assert resp_json["detail"][0]["msg"].startswith("String should match pattern")
105 @pytest.mark.parametrize(
106 "path",
107 [
108 pytest.param("%2e%2e/etc/passwd", id="parent-traversal"),
109 pytest.param("foo/%2e%2e/%2e%2e/etc/passwd", id="nested-traversal"),
110 pytest.param("foo/%2e%2e/bar", id="mid-path-traversal"),
111 ],
112 )
113 def test_path_traversal_returns_422(self, client: TestClient, path: str):
114 resp = client.get(f"/coverage/{SITE_ID}/{path}")
116 decoded_path = path.replace("%2e", ".")
117 assert resp.request.url.path == f"/coverage/{SITE_ID}/{decoded_path}"
119 assert resp.status_code == 422
120 resp_json = resp.json()
121 assert resp_json["detail"][0]["loc"] == ["path", "path"]
122 assert resp_json["detail"][0]["msg"].startswith("String should match pattern")