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

1from unittest.mock import AsyncMock 

2 

3import pytest 

4from botocore.exceptions import ClientError 

5from fastapi.testclient import TestClient 

6 

7SITE_ID = "aabbccddeeff" 

8 

9 

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} 

14 

15 

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>") 

19 

20 resp = client.get(f"/coverage/{SITE_ID}/index.html") 

21 

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 ) 

29 

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 {}") 

34 

35 resp = client.get(f"/coverage/{SITE_ID}/style.css") 

36 

37 assert resp.status_code == 200 

38 assert resp.headers["content-type"] == "text/css; charset=utf-8" 

39 assert resp.content == b"body {}" 

40 

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") 

45 

46 resp = client.get(f"/coverage/{SITE_ID}/file.unknownext") 

47 

48 assert resp.status_code == 200 

49 assert resp.headers["content-type"] == "application/octet-stream" 

50 assert resp.content == b"data" 

51 

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 ) 

59 

60 resp = client.get(f"/coverage/{SITE_ID}/missing.html") 

61 

62 assert resp.status_code == 404 

63 

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>") 

68 

69 client.get(f"/coverage/{SITE_ID}/") 

70 

71 mock_s3_client.get_object.assert_awaited_once_with( 

72 Bucket="test-bucket", 

73 Key=f"sites/{SITE_ID}/index.html", 

74 ) 

75 

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>") 

80 

81 client.get(f"/coverage/{SITE_ID}/subdir/") 

82 

83 mock_s3_client.get_object.assert_awaited_once_with( 

84 Bucket="test-bucket", 

85 Key=f"sites/{SITE_ID}/subdir/index.html", 

86 ) 

87 

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") 

99 

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") 

104 

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}") 

115 

116 decoded_path = path.replace("%2e", ".") 

117 assert resp.request.url.path == f"/coverage/{SITE_ID}/{decoded_path}" 

118 

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")