diff options
| author | Frederick Muriuki Muriithi | 2026-05-27 13:49:30 -0500 |
|---|---|---|
| committer | Frederick Muriuki Muriithi | 2026-05-27 13:49:30 -0500 |
| commit | 1655d8e45bbf8b0524af6529c142ada98ddace54 (patch) | |
| tree | 3016c16af0bb4f2e57fffb22264e22ac6b1fc6bb | |
| download | gn-integration-tests-1655d8e45bbf8b0524af6529c142ada98ddace54.tar.gz | |
Initialise with smoke tests for various GeneNetwork services.
The code in this commit was written by claude code, and is yet to be reviewed. Expect changes once the code has been reviewed.
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README.md | 92 | ||||
| -rw-r--r-- | conftest.py | 89 | ||||
| -rw-r--r-- | pyproject.toml | 18 | ||||
| -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 |
8 files changed, 798 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..301718d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/**/__pycache__ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..69ba217 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# gn-integration-tests + +Integration and end-to-end tests for the GeneNetwork stack +([gn2](https://github.com/genenetwork/genenetwork2), +[gn3](https://git.genenetwork.org/genenetwork3), +[gn-auth](https://git.genenetwork.org/gn-auth)). + +Tests run against a live deployment (CD or production) — no mocking, no +local stack required for the smoke suite. + +## Quick start + +```sh +pip install -e . # or: pip install requests pytest +pytest -m "smoke" # run all smoke tests (no credentials needed) +``` + +By default tests target **CD** (`cd.genenetwork.org` / `auth-cd.genenetwork.org`). +Override with environment variables: + +| Variable | Default | Description | +|---|---|---| +| `GN2_BASE_URL` | `https://cd.genenetwork.org` | genenetwork2 root | +| `GN3_BASE_URL` | `https://cd.genenetwork.org/api3` | gn3 API proxy path | +| `GN_AUTH_BASE_URL` | `https://auth-cd.genenetwork.org` | gn-auth server | + +To target **production**: + +```sh +GN2_BASE_URL=https://genenetwork.org \ +GN3_BASE_URL=https://genenetwork.org/api3 \ +GN_AUTH_BASE_URL=https://auth.genenetwork.org \ +pytest -m "smoke" +``` + +## Test marks + +| Mark | Description | +|---|---| +| `smoke` | Public endpoints, no credentials, fast | +| `gn2` | Tests targeting genenetwork2 | +| `gn3` | Tests targeting the gn3 REST API | +| `gn_auth` | Tests targeting gn-auth | +| `auth_flow` | Requires valid test-user credentials (see below) | + +Run a specific service: + +```sh +pytest -m "gn_auth and smoke" +pytest -m "gn3 and smoke" +pytest -m "gn2 and smoke" +``` + +## Auth-flow tests (Phase 2) + +Set the following environment variables to enable credential-backed tests: + +```sh +export GN_TEST_EMAIL=testuser@example.com +export GN_TEST_PASSWORD=secret +export GN_OAUTH2_CLIENT_ID=<uuid> +export GN_OAUTH2_CLIENT_SECRET=<secret> +pytest -m "auth_flow" +``` + +Without these variables the `auth_flow` tests are automatically skipped. + +## CI integration (planned) + +Each component repo's CI triggers only its relevant mark after a successful +per-repo build: + +- genenetwork2 CI → `pytest -m gn2` +- genenetwork3 CI → `pytest -m gn3` +- gn-auth CI → `pytest -m gn_auth` + +Target: `cd.genenetwork.org` before merges; nightly against production. + +## URL structure notes + +nginx on the deployment host rewrites `/api3/<path>` → `/api/<path>` before +proxying to gn3, and gn-auth lives on a separate subdomain: + +``` +https://cd.genenetwork.org/ → gn2 +https://cd.genenetwork.org/api3/ → gn3 (/api3/foo → gn3's /api/foo) +https://auth-cd.genenetwork.org/ → gn-auth + +https://genenetwork.org/ → gn2 (production) +https://genenetwork.org/api3/ → gn3 (production) +https://auth.genenetwork.org/ → gn-auth (production) +``` diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..5b5eb87 --- /dev/null +++ b/conftest.py @@ -0,0 +1,89 @@ +""" +Shared fixtures for the GeneNetwork integration test suite. + +Base URLs default to the CD environment. Override via environment variables: + + GN2_BASE_URL e.g. https://genenetwork.org (default: CD) + GN3_BASE_URL e.g. https://genenetwork.org/api3 (default: CD) + GN_AUTH_BASE_URL e.g. https://auth.genenetwork.org (default: CD) + +For auth-flow tests also set: + + GN_TEST_EMAIL registered test-user e-mail address + GN_TEST_PASSWORD password for GN_TEST_EMAIL + GN_OAUTH2_CLIENT_ID OAuth2 client UUID + GN_OAUTH2_CLIENT_SECRET OAuth2 client secret +""" + +import os +import pytest +import requests + + +_GN2_DEFAULT = "https://cd.genenetwork.org" +_GN3_DEFAULT = "https://cd.genenetwork.org/api3" +_GN_AUTH_DEFAULT = "https://auth-cd.genenetwork.org" + + +@pytest.fixture(scope="session") +def gn2_url() -> str: + return os.environ.get("GN2_BASE_URL", _GN2_DEFAULT).rstrip("/") + + +@pytest.fixture(scope="session") +def gn3_url() -> str: + return os.environ.get("GN3_BASE_URL", _GN3_DEFAULT).rstrip("/") + + +@pytest.fixture(scope="session") +def gn_auth_url() -> str: + return os.environ.get("GN_AUTH_BASE_URL", _GN_AUTH_DEFAULT).rstrip("/") + + +@pytest.fixture(scope="session") +def http() -> requests.Session: + """Shared requests.Session; sets a conservative timeout for all calls.""" + with requests.Session() as session: + session.headers.update({"Accept": "application/json"}) + yield session + + +# --------------------------------------------------------------------------- +# Auth-flow helpers (Phase 2 tests) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def oauth2_credentials(): + """Returns (email, password, client_id, client_secret) or skips the test.""" + email = os.environ.get("GN_TEST_EMAIL") + password = os.environ.get("GN_TEST_PASSWORD") + client_id = os.environ.get("GN_OAUTH2_CLIENT_ID") + client_secret = os.environ.get("GN_OAUTH2_CLIENT_SECRET") + if not all([email, password, client_id, client_secret]): + pytest.skip( + "Set GN_TEST_EMAIL, GN_TEST_PASSWORD, GN_OAUTH2_CLIENT_ID, and " + "GN_OAUTH2_CLIENT_SECRET to run auth-flow tests." + ) + return email, password, client_id, client_secret + + +@pytest.fixture(scope="session") +def access_token(gn_auth_url, oauth2_credentials, http): + """Obtains a Bearer token via the password grant and caches it for the session.""" + email, password, client_id, client_secret = oauth2_credentials + resp = http.post( + f"{gn_auth_url}/auth/token", + json={ + "grant_type": "password", + "username": email, + "password": password, + "scope": "profile group resource", + "client_id": client_id, + "client_secret": client_secret, + }, + timeout=30, + ) + assert resp.status_code == 200, f"Token request failed: {resp.text}" + data = resp.json() + assert "access_token" in data + return data["access_token"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6a8f9a4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "gn-integration-tests" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "requests", + "pytest", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "smoke: Quick sanity checks requiring no authentication", + "gn2: Tests exercising genenetwork2 directly", + "gn3: Tests exercising the genenetwork3 REST API", + "gn_auth: Tests exercising the gn-auth service", + "auth_flow: Tests requiring valid user credentials (set GN_TEST_EMAIL and GN_TEST_PASSWORD)", +] 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 |
