diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/__init__.py | 0 | ||||
| -rw-r--r-- | tests/test_gn2_smoke.py | 167 | ||||
| -rw-r--r-- | tests/test_gn3_smoke.py | 215 | ||||
| -rw-r--r-- | tests/test_gn_auth_smoke.py | 216 |
4 files changed, 598 insertions, 0 deletions
diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/test_gn2_smoke.py b/tests/test_gn2_smoke.py new file mode 100644 index 0000000..7c23642 --- /dev/null +++ b/tests/test_gn2_smoke.py @@ -0,0 +1,167 @@ +""" +Smoke tests for the genenetwork2 web frontend. + +These tests make HTTP requests to gn2 and assert on status codes and key +page content. They do not require authentication and do not parse full +HTML — just enough to confirm the page rendered without error and the +expected structural marker is present. + +Run with: + + pytest -m "gn2 and smoke" +""" + +import pytest + + +pytestmark = [pytest.mark.gn2, pytest.mark.smoke] + +# --------------------------------------------------------------------------- +# Known-good test data +# --------------------------------------------------------------------------- +_DATASET = "HC_M2_0606_P" +_TRAIT_ID = "1435395_s_at" +_SEARCH_SPECIES = "mouse" +_SEARCH_GROUP = "BXD" +_SEARCH_TYPE = "Hippocampus mRNA" + + +# --------------------------------------------------------------------------- +# Home page +# --------------------------------------------------------------------------- + +class TestHomePage: + def test_returns_200(self, gn2_url, http): + resp = http.get(gn2_url + "/", timeout=30) + assert resp.status_code == 200 + + def test_content_type_is_html(self, gn2_url, http): + resp = http.get(gn2_url + "/", timeout=30) + assert "text/html" in resp.headers.get("Content-Type", "") + + def test_search_form_present(self, gn2_url, http): + resp = http.get(gn2_url + "/", timeout=30) + # The search button has id="btsearch" (from existing gn2 test suite). + assert "btsearch" in resp.text, ( + "Search button element (#btsearch) not found on home page" + ) + + +# --------------------------------------------------------------------------- +# Dataset search +# --------------------------------------------------------------------------- + +class TestSearch: + def test_search_returns_200(self, gn2_url, http): + params = { + "species": _SEARCH_SPECIES, + "group": _SEARCH_GROUP, + "type": _SEARCH_TYPE, + "dataset": _DATASET, + "search_terms_or": "", + "search_terms_and": "MEAN=(15 16) LRS=(23 46)", + } + resp = http.get(gn2_url + "/search", params=params, timeout=60) + assert resp.status_code == 200 + + def test_search_shows_results_found(self, gn2_url, http): + params = { + "species": _SEARCH_SPECIES, + "group": _SEARCH_GROUP, + "type": _SEARCH_TYPE, + "dataset": _DATASET, + "search_terms_or": "", + "search_terms_and": "MEAN=(15 16) LRS=(23 46)", + } + resp = http.get(gn2_url + "/search", params=params, timeout=60) + assert "records found" in resp.text, ( + "Search results page does not contain 'records found'" + ) + + +# --------------------------------------------------------------------------- +# Trait page +# --------------------------------------------------------------------------- + +class TestTraitPage: + def test_known_trait_returns_200(self, gn2_url, http): + resp = http.get( + gn2_url + "/show_trait", + params={"trait_id": _TRAIT_ID, "dataset": _DATASET}, + timeout=60, + ) + assert resp.status_code == 200, ( + f"Expected 200 for show_trait?trait_id={_TRAIT_ID}&dataset={_DATASET}, " + f"got {resp.status_code}" + ) + + def test_trait_page_has_data_form(self, gn2_url, http): + resp = http.get( + gn2_url + "/show_trait", + params={"trait_id": _TRAIT_ID, "dataset": _DATASET}, + timeout=60, + ) + assert 'id="trait_data_form"' in resp.text, ( + "Trait page is missing the #trait_data_form element" + ) + + def test_trait_page_references_dataset(self, gn2_url, http): + resp = http.get( + gn2_url + "/show_trait", + params={"trait_id": _TRAIT_ID, "dataset": _DATASET}, + timeout=60, + ) + assert _DATASET in resp.text, ( + f"Dataset name '{_DATASET}' not found in trait page" + ) + + def test_unknown_trait_does_not_500(self, gn2_url, http): + resp = http.get( + gn2_url + "/show_trait", + params={"trait_id": "nonexistent_00000", "dataset": "NONEXISTENT_XYZ"}, + timeout=60, + ) + assert resp.status_code != 500, ( + f"Unknown trait/dataset caused 500: {resp.text[:500]}" + ) + + +# --------------------------------------------------------------------------- +# gn2 → gn-auth authorization call-through +# +# When gn2 renders the trait page for a public trait, it calls gn-auth's +# POST /auth/data/authorisation internally. A 200 response with the data +# form present confirms that the gn2 → gn-auth auth call succeeded. +# --------------------------------------------------------------------------- + +class TestGn2ToGnAuthAuthFlow: + def test_public_trait_page_renders_without_auth(self, gn2_url, http): + """ + The trait page for a known public trait must render fully without + the user being logged in. A 200 response with the data form confirms + that gn2 successfully called gn-auth's data/authorisation endpoint + and got a public-access response. + """ + resp = http.get( + gn2_url + "/show_trait", + params={"trait_id": _TRAIT_ID, "dataset": _DATASET}, + timeout=60, + ) + assert resp.status_code == 200 + assert 'id="trait_data_form"' in resp.text + + +# --------------------------------------------------------------------------- +# gn2 OAuth2 endpoints — basic reachability +# --------------------------------------------------------------------------- + +class TestOAuth2Endpoints: + def test_login_page_reachable(self, gn2_url, http): + """The OAuth2 authorise redirect must be reachable (not 500).""" + resp = http.get(gn2_url + "/login", timeout=30, allow_redirects=True) + assert resp.status_code != 500 + + def test_oauth2_code_endpoint_without_code_does_not_500(self, gn2_url, http): + """The callback endpoint without a code param should 400/redirect, not 500.""" + resp = http.get(gn2_url + "/oauth2/code", timeout=30, allow_redirects=False) + assert resp.status_code != 500 diff --git a/tests/test_gn3_smoke.py b/tests/test_gn3_smoke.py new file mode 100644 index 0000000..b3e4e9f --- /dev/null +++ b/tests/test_gn3_smoke.py @@ -0,0 +1,215 @@ +""" +Smoke tests for the genenetwork3 REST API. + +All tests hit public, unauthenticated endpoints. The gn3 API is proxied +at /api3/ on the gn2 domain; nginx rewrites /api3/<path> → /api/<path> +before forwarding to gn3, so the URL seen by the caller is: + + https://cd.genenetwork.org/api3/metadata/species + (→ gn3 internal: /api/metadata/species) + +Response format note: gn3 metadata endpoints return JSON-LD, shaped as: + + {"@context": {...}, "data": [...]} + +The actual payload is always in the "data" key. On the CD environment the +RDF/SPARQL store may be empty, so "data" can be []; production will have +real entries. Content assertions (e.g. "contains mouse") are therefore +skipped when the data list is empty. + +Run with: + + pytest -m "gn3 and smoke" + +Known CD/production bugs (as of 2026-05-27): + - POST /api3/metadata/datasets/edit returns 500 on both CD and production + due to TypeError in privileges_fulfill_specs() — tracked separately. +""" + +import pytest + + +pytestmark = [pytest.mark.gn3, pytest.mark.smoke] + +# --------------------------------------------------------------------------- +# Known-good test data (sourced from existing gn2/gn3 test fixtures) +# --------------------------------------------------------------------------- +_KNOWN_DATASET = "HC_M2_0606_P" # Hippocampus Consortium M430v2 Jun06 PDNN +_KNOWN_SPECIES = "mouse" +_KNOWN_GROUP = "BXD" +_KNOWN_PROBESET = "1435395_s_at" +_KNOWN_INBREDSET_ID = 1 # BXD inbredset_id in the database + + +def _jsonld_data(resp): + """Return the 'data' list from a JSON-LD gn3 response.""" + body = resp.json() + assert isinstance(body, dict), f"Expected JSON object, got: {type(body)}" + assert "data" in body, f"Missing 'data' key in JSON-LD response: {list(body.keys())}" + assert isinstance(body["data"], list), ( + f"'data' should be a list, got {type(body['data'])}" + ) + return body["data"] + + +# --------------------------------------------------------------------------- +# GET /api3/metadata/species +# --------------------------------------------------------------------------- + +class TestMetadataSpecies: + def test_returns_200(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/species", timeout=30) + assert resp.status_code == 200 + + def test_response_is_jsonld_with_data_key(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/species", timeout=30) + data = _jsonld_data(resp) # asserts structure internally + assert isinstance(data, list) + + def test_contains_mouse_when_data_populated(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/species", timeout=30) + items = _jsonld_data(resp) + if not items: + pytest.skip("Species data is empty in this environment (RDF store not populated)") + all_names = [ + s.get("fullName", s.get("name", s.get("shortName", ""))) + for s in items + ] + assert any("mouse" in n.lower() or "mus" in n.lower() for n in all_names), ( + f"Mouse not found in species list: {all_names}" + ) + + def test_species_entry_schema_when_data_populated(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/species", timeout=30) + items = _jsonld_data(resp) + if not items: + pytest.skip("Species data is empty in this environment") + entry = items[0] + assert any(k in entry for k in ("fullName", "name", "shortName")), ( + f"Species entry has no recognisable name field: {entry}" + ) + + +# --------------------------------------------------------------------------- +# GET /api3/metadata/species/<name> +# --------------------------------------------------------------------------- + +class TestMetadataSpeciesByName: + def test_known_species_returns_200(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/species/{_KNOWN_SPECIES}", timeout=30) + assert resp.status_code == 200, ( + f"Expected 200 for species '{_KNOWN_SPECIES}', " + f"got {resp.status_code}: {resp.text}" + ) + + def test_unknown_species_does_not_500(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/species/nonexistent_xyz", timeout=30) + assert resp.status_code != 500, ( + f"Unknown species caused 500: {resp.text}" + ) + + +# --------------------------------------------------------------------------- +# GET /api3/metadata/groups +# --------------------------------------------------------------------------- + +class TestMetadataGroups: + def test_returns_200(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/groups", timeout=30) + assert resp.status_code == 200 + + def test_response_is_jsonld_with_data_key(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/groups", timeout=30) + data = _jsonld_data(resp) + assert isinstance(data, list) + + def test_contains_bxd_when_data_populated(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/groups", timeout=30) + items = _jsonld_data(resp) + if not items: + pytest.skip("Groups data is empty in this environment") + names = [g.get("name", g.get("shortName", "")) for g in items] + assert "BXD" in names, f"BXD not found in groups: {names[:20]}" + + +# --------------------------------------------------------------------------- +# GET /api3/metadata/datasets/<name> +# --------------------------------------------------------------------------- + +class TestMetadataDatasets: + def test_known_dataset_returns_200(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/datasets/{_KNOWN_DATASET}", timeout=30) + assert resp.status_code == 200, ( + f"Expected 200 for dataset '{_KNOWN_DATASET}', " + f"got {resp.status_code}: {resp.text}" + ) + + def test_known_dataset_response_is_jsonld(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/datasets/{_KNOWN_DATASET}", timeout=30) + body = resp.json() + assert isinstance(body, dict), f"Expected JSON object, got {type(body)}" + assert "@context" in body or "data" in body, ( + f"Response does not look like JSON-LD: {list(body.keys())}" + ) + + def test_unknown_dataset_does_not_500(self, gn3_url, http): + resp = http.get(f"{gn3_url}/metadata/datasets/NONEXISTENT_XYZ", timeout=30) + assert resp.status_code != 500, ( + f"Unknown dataset caused 500: {resp.text}" + ) + + +# --------------------------------------------------------------------------- +# GET /api3/metadata/probesets/<dataset>/<name> +# --------------------------------------------------------------------------- + +class TestMetadataProbesets: + def test_known_probeset_returns_200(self, gn3_url, http): + resp = http.get( + f"{gn3_url}/metadata/probesets/{_KNOWN_DATASET}/{_KNOWN_PROBESET}", + timeout=30, + ) + assert resp.status_code == 200, ( + f"Expected 200 for probeset '{_KNOWN_DATASET}/{_KNOWN_PROBESET}', " + f"got {resp.status_code}: {resp.text}" + ) + + +# --------------------------------------------------------------------------- +# GET /api3/case-attribute/<inbredset_id> +# --------------------------------------------------------------------------- + +class TestCaseAttributes: + def test_known_inbredset_does_not_500(self, gn3_url, http): + resp = http.get( + f"{gn3_url}/case-attribute/{_KNOWN_INBREDSET_ID}", + timeout=30, + ) + assert resp.status_code != 500, ( + f"case-attribute inbredset_id={_KNOWN_INBREDSET_ID} caused 500: {resp.text}" + ) + + +# --------------------------------------------------------------------------- +# Protected endpoints — known bug +# --------------------------------------------------------------------------- + +@pytest.mark.xfail( + reason=( + "BUG: POST /api3/metadata/datasets/edit returns 500 on both CD and " + "production due to TypeError: privileges_fulfill_specs() missing 1 " + "required positional argument: 'system_privileges' " + "(gn3/api/metadata.py:279). Should return 401 without a token." + ), + strict=True, +) +def test_metadata_edit_without_token_returns_401(gn3_url, http): + resp = http.post( + f"{gn3_url}/metadata/datasets/edit", + json={}, + timeout=30, + ) + assert resp.status_code == 401, ( + f"Expected 401 on /metadata/datasets/edit without token, " + f"got {resp.status_code}: {resp.text}" + ) diff --git a/tests/test_gn_auth_smoke.py b/tests/test_gn_auth_smoke.py new file mode 100644 index 0000000..7dd9645 --- /dev/null +++ b/tests/test_gn_auth_smoke.py @@ -0,0 +1,216 @@ +""" +Smoke tests for the gn-auth service. + +Run with no credentials against CD (or production): + + pytest -m "gn_auth and smoke" + +These tests verify the public-facing contract of the three authorization +endpoints called at request time by gn2 and gn3, plus the JWKS endpoint +used for JWT validation. +""" + +import pytest + + +pytestmark = [pytest.mark.gn_auth, pytest.mark.smoke] + +# --------------------------------------------------------------------------- +# Known public trait used in authorisation tests. +# HC_M2_0606_P (Hippocampus Consortium M430v2) is a public dataset with +# extensive test coverage across gn2/gn3. +# --------------------------------------------------------------------------- +_PUBLIC_TRAIT = "HC_M2_0606_P::1435395_s_at" +_PUBLIC_PHENOTYPE_TRAIT = "BXDPublish::10710" +_NONEXISTENT_TRAIT = "NONEXISTENT_DATASET_XYZ::00000" + + +# --------------------------------------------------------------------------- +# GET /auth/public-jwks +# --------------------------------------------------------------------------- + +class TestPublicJwks: + def test_returns_200(self, gn_auth_url, http): + resp = http.get(f"{gn_auth_url}/auth/public-jwks", timeout=30) + assert resp.status_code == 200 + + def test_response_has_jwks_key(self, gn_auth_url, http): + resp = http.get(f"{gn_auth_url}/auth/public-jwks", timeout=30) + data = resp.json() + assert "jwks" in data, f"Missing 'jwks' key in response: {data}" + + def test_at_least_one_rsa_key(self, gn_auth_url, http): + resp = http.get(f"{gn_auth_url}/auth/public-jwks", timeout=30) + jwks = resp.json().get("jwks", []) + assert len(jwks) >= 1, "Expected at least one key in JWKS" + key = jwks[-1] # newest key + assert key.get("kty") == "RSA" + # "alg" is optional per RFC 7517 — gn-auth omits it + assert "n" in key and "e" in key, "RSA key missing modulus or exponent" + assert "kid" in key, "RSA key missing 'kid'" + + +# --------------------------------------------------------------------------- +# GET /auth/system/roles (no token → public-view only) +# --------------------------------------------------------------------------- + +class TestSystemRolesUnauthenticated: + def test_returns_200_without_token(self, gn_auth_url, http): + resp = http.get(f"{gn_auth_url}/auth/system/roles", timeout=30) + assert resp.status_code == 200 + + def test_response_is_list(self, gn_auth_url, http): + resp = http.get(f"{gn_auth_url}/auth/system/roles", timeout=30) + data = resp.json() + assert isinstance(data, list), f"Expected list, got: {type(data)}" + + def test_contains_public_view_role(self, gn_auth_url, http): + resp = http.get(f"{gn_auth_url}/auth/system/roles", timeout=30) + roles = resp.json() + names = [r.get("role_name") for r in roles] + assert "public-view" in names, ( + f"Expected 'public-view' role when called without auth token; got: {names}" + ) + + def test_public_view_role_has_privileges(self, gn_auth_url, http): + resp = http.get(f"{gn_auth_url}/auth/system/roles", timeout=30) + public_view = next( + (r for r in resp.json() if r.get("role_name") == "public-view"), None + ) + assert public_view is not None + assert isinstance(public_view.get("privileges"), list) + assert len(public_view["privileges"]) >= 1 + + def test_role_schema(self, gn_auth_url, http): + resp = http.get(f"{gn_auth_url}/auth/system/roles", timeout=30) + for role in resp.json(): + assert "role_id" in role, f"Role missing 'role_id': {role}" + assert "role_name" in role, f"Role missing 'role_name': {role}" + assert "privileges" in role, f"Role missing 'privileges': {role}" + + +# --------------------------------------------------------------------------- +# POST /auth/data/authorisation +# --------------------------------------------------------------------------- + +class TestDataAuthorisation: + def test_known_public_probeset_trait_returns_200(self, gn_auth_url, http): + resp = http.post( + f"{gn_auth_url}/auth/data/authorisation", + json={"traits": [_PUBLIC_TRAIT]}, + timeout=30, + ) + assert resp.status_code == 200, ( + f"Expected 200 for known public trait '{_PUBLIC_TRAIT}', " + f"got {resp.status_code}: {resp.text}" + ) + + def test_known_public_trait_response_structure(self, gn_auth_url, http): + resp = http.post( + f"{gn_auth_url}/auth/data/authorisation", + json={"traits": [_PUBLIC_TRAIT]}, + timeout=30, + ) + data = resp.json() + assert "authorisation" in data, f"Missing 'authorisation' key: {data}" + assert isinstance(data["authorisation"], list) + assert len(data["authorisation"]) >= 1 + + def test_known_public_trait_has_view_privilege(self, gn_auth_url, http): + resp = http.post( + f"{gn_auth_url}/auth/data/authorisation", + json={"traits": [_PUBLIC_TRAIT]}, + timeout=30, + ) + resources = resp.json()["authorisation"] + all_privileges = [p for r in resources for p in r.get("privileges", [])] + assert any("view" in p for p in all_privileges), ( + f"Expected a view privilege for public trait '{_PUBLIC_TRAIT}'; " + f"got privileges: {all_privileges}" + ) + + def test_resource_entry_schema(self, gn_auth_url, http): + # Actual response schema (verified against CD 2026-05-27): + # {"resource_id": str, "resource_data": [...], "privileges": [...]} + # Note: "public" and "resource_name" fields are NOT present in the + # deployed response despite appearing in source comments. + resp = http.post( + f"{gn_auth_url}/auth/data/authorisation", + json={"traits": [_PUBLIC_TRAIT]}, + timeout=30, + ) + for resource in resp.json()["authorisation"]: + assert "resource_id" in resource, f"Missing resource_id: {resource}" + assert "resource_data" in resource, f"Missing resource_data: {resource}" + assert "privileges" in resource, f"Missing privileges: {resource}" + assert isinstance(resource["resource_data"], list) + assert isinstance(resource["privileges"], list) + + def test_known_public_phenotype_trait(self, gn_auth_url, http): + resp = http.post( + f"{gn_auth_url}/auth/data/authorisation", + json={"traits": [_PUBLIC_PHENOTYPE_TRAIT]}, + timeout=30, + ) + assert resp.status_code == 200, ( + f"Expected 200 for '{_PUBLIC_PHENOTYPE_TRAIT}', " + f"got {resp.status_code}: {resp.text}" + ) + + def test_nonexistent_trait_returns_404(self, gn_auth_url, http): + resp = http.post( + f"{gn_auth_url}/auth/data/authorisation", + json={"traits": [_NONEXISTENT_TRAIT]}, + timeout=30, + ) + assert resp.status_code == 404, ( + f"Expected 404 for nonexistent trait '{_NONEXISTENT_TRAIT}', " + f"got {resp.status_code}: {resp.text}" + ) + + def test_empty_traits_list_is_handled(self, gn_auth_url, http): + resp = http.post( + f"{gn_auth_url}/auth/data/authorisation", + json={"traits": []}, + timeout=30, + ) + # Must not be a 500; 200 with empty result or 400/404 are all acceptable. + assert resp.status_code != 500, ( + f"Empty traits list caused 500: {resp.text}" + ) + + def test_multiple_traits_in_one_request(self, gn_auth_url, http): + resp = http.post( + f"{gn_auth_url}/auth/data/authorisation", + json={"traits": [_PUBLIC_TRAIT, _PUBLIC_PHENOTYPE_TRAIT]}, + timeout=30, + ) + assert resp.status_code == 200 + data = resp.json() + assert "authorisation" in data + + +# --------------------------------------------------------------------------- +# GET /auth/system/roles (with valid token — Phase 2) +# --------------------------------------------------------------------------- + +@pytest.mark.auth_flow +class TestSystemRolesAuthenticated: + def test_returns_200_with_token(self, gn_auth_url, http, access_token): + resp = http.get( + f"{gn_auth_url}/auth/system/roles", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + assert resp.status_code == 200 + + def test_authenticated_user_has_more_than_public_view(self, gn_auth_url, http, access_token): + resp = http.get( + f"{gn_auth_url}/auth/system/roles", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + roles = resp.json() + names = [r.get("role_name") for r in roles] + # A logged-in user should see public-view plus at least their own roles. + assert "public-view" in names |
