diff --git a/server/Makefile b/server/Makefile index b04ed7428..20a67e79e 100644 --- a/server/Makefile +++ b/server/Makefile @@ -25,3 +25,10 @@ test-annotations-performance: .PHONY: test-annotations-scale test-annotations-scale: locust -f tests/performance/scale_test_annotations.py --headless -u 30 -r 10 --host https://api.cellxgene.dev.single-cell.czi.technology/cellxgene/e/ --run-time 5m 2>&1 | tee locust_dev_stats.txt + +.PHONY: e2e +e2e: + python -m unittest discover \ + --start-directory tests/e2e \ + --top-level-directory .. \ + --verbose; test_result=$$?; \ diff --git a/server/requirements-dev.txt b/server/requirements-dev.txt index 12f505994..aa765005a 100644 --- a/server/requirements-dev.txt +++ b/server/requirements-dev.txt @@ -5,6 +5,7 @@ codecov>=2.0.15 parameterized>=0.7.0 psycopg2-binary>=2.8.5 pytest>=3.6.3 +pytest-subtests>=0.5.0 python-jose>=3.2.0 twine>=1.12.1 -r requirements.txt diff --git a/server/tests/e2e/__init__.py b/server/tests/e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/tests/e2e/test_api_cache.py b/server/tests/e2e/test_api_cache.py new file mode 100644 index 000000000..feecd2f4e --- /dev/null +++ b/server/tests/e2e/test_api_cache.py @@ -0,0 +1,94 @@ +import json +import os +import unittest +from urllib.parse import quote + +import requests + + +@unittest.skipIf(os.getenv("CXG_API_BASE") is None, "CXG_API_BASE not defined") +class TestAPICache(unittest.TestCase): + """These tests must run against a deployed API backed by AWS cloudfront.""" + + def setUp(self): + self.api_url_base = os.environ["CXG_API_BASE"].rstrip("/") + response = requests.get("/".join([self.api_url_base, "dp/v1/collections"])) + response.raise_for_status() + collection_id = response.json()["collections"][0]["id"] + response = requests.get("/".join([self.api_url_base, f"dp/v1/collections/{collection_id}"])) + response.raise_for_status() + self.dataset_id = response.json()["datasets"][0]["id"] + self.explorer_api_url_base = "/".join([self.api_url_base, "cellxgene", "e", f"{self.dataset_id}.cxg"]) + self.cloudfront_miss = "Miss from cloudfront" + self.cloudfront_hit = "Hit from cloudfront" + + @staticmethod + def generate_error_msg(response, message): + return json.dumps( + {"msg": message, "x-cache": response.headers["x-cache"], "x-amz-cf-id": response.headers["x-amz-cf-id"]} + ) + + def test_uncached_endpoints(self): + """Verify the cache headers are properly set for uncached endpoints""" + error_message = "Miss Expected" + endpoints = ["api/v0.3/dataset-metadata", "api/v0.3/s3_uri"] + + def verify_cache_control(_response): + _response.raise_for_status() + cache_control = _response.headers["cache-control"] + self.assertIn("no-store", cache_control) + self.assertIn("max-age=0", cache_control) + self.assertIn("public", cache_control) + + for endpoint in endpoints: + with self.subTest(endpoint): + # Test + url = "/".join([self.explorer_api_url_base, endpoint]) + response = requests.head(url) + + # Verify + verify_cache_control(response) + self.assertEqual( + response.headers["x-cache"], + self.cloudfront_miss, + msg=self.generate_error_msg(response, error_message), + ) + + # Call the request twice to make sure the cache wasn't cold. + response = requests.head(url) + verify_cache_control(response) + self.assertEqual( + response.headers["x-cache"], + self.cloudfront_miss, + msg=self.generate_error_msg(response, error_message), + ) + + def test_cached_endpoints(self): + """Verify cloudfront caching headers are properly set for cached endpoints""" + + response = requests.get("/".join([self.explorer_api_url_base, "api/v0.3/s3_uri"])) + response.raise_for_status() + body = response.json() + url_base = "/".join( + [self.api_url_base, "cellxgene", "s3_uri", quote(quote(body, safe=""), safe=""), "api/v0.3"] + ) + endpoints = ["config", "schema", "colors", "genesets", "layout/obs", "annotations/obs", "annotations/var"] + + for endpoint in endpoints: + with self.subTest(endpoint): + # Test + url = "/".join([url_base, endpoint]) + response = requests.head(url) + + # Verify + response.raise_for_status() + self.assertIn(response.headers["x-cache"], [self.cloudfront_miss, self.cloudfront_hit]) + + # Call the request twice to make sure the cache wasn't cold. + response = requests.head(url) + response.raise_for_status() + self.assertEqual( + response.headers["x-cache"], + self.cloudfront_hit, + msg=self.generate_error_msg(response, "Hit Expected"), + ) diff --git a/server/tests/unit/common/apis/test_api_v2.py b/server/tests/unit/common/apis/test_api_v2.py index bc9865894..07922714c 100644 --- a/server/tests/unit/common/apis/test_api_v2.py +++ b/server/tests/unit/common/apis/test_api_v2.py @@ -745,6 +745,7 @@ def setUpClass(cls): app__flask_secret_key="testing", app__debug=True, data_locator__s3__region_name="us-east-1", + app__generate_cache_control_headers=True, ) cls.meta_response_body = { "collection_id": "4f098ff4-4a12-446b-a841-91ba3d8e3fa6", @@ -758,6 +759,12 @@ def setUpClass(cls): cls.app.testing = True cls.client = cls.app.test_client() + def verify_response(self, result): + self.assertEqual(result.status_code, HTTPStatus.OK) + self.assertEqual(result.headers["Content-Type"], "application/json") + self.assertTrue(result.cache_control.no_store) + self.assertEqual(result.cache_control.max_age, 0) + @patch("server.data_common.dataset_metadata.request_dataset_metadata_from_data_portal") @patch("server.data_common.dataset_metadata.requests.get") def test_dataset_metadata_api_called_for_public_collection(self, mock_get, mock_dp): @@ -790,8 +797,7 @@ def test_dataset_metadata_api_called_for_public_collection(self, mock_get, mock_ url = f"{self.TEST_URL_BASE}{endpoint}" result = self.client.get(url) - self.assertEqual(result.status_code, HTTPStatus.OK) - self.assertEqual(result.headers["Content-Type"], "application/json") + self.verify_response(result) self.assertEqual(mock_get.call_count, 1) @@ -843,8 +849,7 @@ def test_dataset_metadata_api_called_for_private_collection(self, mock_get, mock url = f"{self.TEST_URL_BASE}{endpoint}" result = self.client.get(url) - self.assertEqual(result.status_code, HTTPStatus.OK) - self.assertEqual(result.headers["Content-Type"], "application/json") + self.verify_response(result) self.assertEqual(mock_get.call_count, 1)