"""Access-control tests for case-attribute endpoints (old flat + v1 hierarchy). Case-attribute names are publicly accessible (no token required). All write endpoints require a valid token, and a token with appropriate edit privileges. """ import uuid import pytest pytestmark = pytest.mark.gn3 _SPECIES_ID = 1 # Mouse _INBREDSET_ID = 1 # BXD _CHANGE_ID = 1 @pytest.mark.case_attr_access @pytest.mark.parametrize("path", [ f"/case-attribute/{_INBREDSET_ID}/edit", f"/v1/species/{_SPECIES_ID}/populations/{_INBREDSET_ID}/case-attributes/edit", ]) def test_no_token_edit_returns_400(gn3_url, http, path): """POST to any edit endpoint with no token must be rejected with 400.""" resp = http.post( f"{gn3_url}{path}", json={"edit-data": []}, timeout=30, ) assert resp.status_code == 400, ( f"Expected 400 for unauthenticated POST {path!r}, " f"got {resp.status_code}. Body: {resp.text[:200]}" ) @pytest.mark.case_attr_access @pytest.mark.auth_flow @pytest.mark.parametrize("path", [ f"/case-attribute/{_INBREDSET_ID}/edit", f"/v1/species/{_SPECIES_ID}/populations/{_INBREDSET_ID}/case-attributes/edit", ]) def test_no_privilege_edit_returns_401(gn3_url, http, basic_access_token, path): """A token with no case-attribute edit privileges must be refused with 401.""" resp = http.post( f"{gn3_url}{path}", json={"edit-data": []}, headers={"Authorization": f"Bearer {basic_access_token}"}, timeout=30, ) assert resp.status_code == 401, ( f"Expected 401 for unprivileged POST {path!r}, " f"got {resp.status_code}. Body: {resp.text[:200]}" ) @pytest.mark.case_attr_access @pytest.mark.auth_flow @pytest.mark.parametrize("path", [ f"/case-attribute/{_INBREDSET_ID}/approve/{_CHANGE_ID}", f"/v1/species/{_SPECIES_ID}/populations/{_INBREDSET_ID}/case-attributes/diffs/{_CHANGE_ID}/approve", ]) def test_no_privilege_approve_returns_401(gn3_url, http, basic_access_token, path): """A token with no approve privileges must be refused with 401.""" resp = http.post( f"{gn3_url}{path}", headers={"Authorization": f"Bearer {basic_access_token}"}, timeout=30, ) assert resp.status_code == 401, ( f"Expected 401 for unprivileged POST {path!r}, " f"got {resp.status_code}. Body: {resp.text[:200]}" ) @pytest.mark.case_attr_access @pytest.mark.auth_flow @pytest.mark.parametrize("path", [ f"/case-attribute/{_INBREDSET_ID}/reject/{_CHANGE_ID}", f"/v1/species/{_SPECIES_ID}/populations/{_INBREDSET_ID}/case-attributes/diffs/{_CHANGE_ID}/reject", ]) def test_no_privilege_reject_returns_401(gn3_url, http, basic_access_token, path): """A token with no reject privileges must be refused with 401.""" resp = http.post( f"{gn3_url}{path}", headers={"Authorization": f"Bearer {basic_access_token}"}, timeout=30, ) assert resp.status_code == 401, ( f"Expected 401 for unprivileged POST {path!r}, " f"got {resp.status_code}. Body: {resp.text[:200]}" ) # --------------------------------------------------------------------------- # Provisioning helpers for Levels 4–5 # --------------------------------------------------------------------------- @pytest.fixture(scope="session") def _admin_full_scope_token(gn_auth_url, http, oauth2_credentials): """Admin Bearer token with user+role scope for provisioning test users.""" 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 user role", "client_id": client_id, "client_secret": client_secret, }, timeout=30, ) assert resp.status_code == 200, ( f"Admin full-scope token request failed: {resp.text}" ) return resp.json()["access_token"] @pytest.fixture(scope="session") def bxd_resource_id(gn_auth_url, http): """Resource UUID for the BXD population (SpeciesId=1, InbredSetId=1). Skips the test suite if the population is not registered in gn-auth. """ resp = http.get( f"{gn_auth_url}/auth/resource/populations/resource-id" f"/{_SPECIES_ID}/{_INBREDSET_ID}", timeout=30, ) if resp.status_code == 404: pytest.skip( f"BXD population (species={_SPECIES_ID}, pop={_INBREDSET_ID}) " "is not registered as a resource in gn-auth — skipping Level 4+ tests." ) assert resp.status_code == 200, ( f"Unexpected {resp.status_code} fetching BXD resource-id: {resp.text}" ) return resp.json()["resource-id"] @pytest.fixture def provisioned_token(gn_auth_url, http, _admin_full_scope_token, oauth2_credentials): """Fixture factory: create a test user, assign role on a resource, yield token, clean up. Usage inside a test:: def test_something(gn3_url, http, provisioned_token, bxd_resource_id): token = provisioned_token("resource-owner", bxd_resource_id) resp = http.post(url, headers={"Authorization": f"Bearer {token}"}) """ *_, client_id, client_secret = oauth2_credentials admin_hdrs = {"Authorization": f"Bearer {_admin_full_scope_token}"} created: list = [] # [(user_id, role_name, resource_id), ...] def _make(role_name: str, resource_id: str) -> str: unique = str(uuid.uuid4())[:8] email = f"test-{role_name}-{unique}@regression.genenetwork.org" password = "GnTest1234!" create_resp = http.post( f"{gn_auth_url}/auth/system/administration/users/create", json={"email": email, "name": f"Test {role_name}", "password": password}, headers=admin_hdrs, timeout=30, ) assert create_resp.status_code == 201, ( f"Failed to create test user for role {role_name!r}: " f"{create_resp.status_code} {create_resp.text}" ) user_id = create_resp.json()["user_id"] created.append((user_id, role_name, resource_id)) assign_resp = http.post( f"{gn_auth_url}/auth/system/administration/resources/{resource_id}/assign-owner", json={"user_id": user_id}, headers=admin_hdrs, timeout=30, ) assert assign_resp.status_code == 200, ( f"Failed to assign resource-owner to user {user_id} on {resource_id}: " f"{assign_resp.status_code} {assign_resp.text}" ) token_resp = http.post( f"{gn_auth_url}/auth/token", json={ "grant_type": "password", "username": email, "password": password, "scope": "profile group resource role user", "client_id": client_id, "client_secret": client_secret, }, timeout=30, ) assert token_resp.status_code == 200, ( f"Failed to get token for test user: {token_resp.status_code} {token_resp.text}" ) return token_resp.json()["access_token"] yield _make for user_id, _role_name, resource_id in created: http.post( f"{gn_auth_url}/auth/system/administration/resources/{resource_id}/revoke-owner", json={"user_id": user_id}, headers=admin_hdrs, timeout=30, ) http.post( f"{gn_auth_url}/auth/system/administration/users/delete", json={"user_ids": [user_id]}, headers=admin_hdrs, timeout=30, ) @pytest.fixture(scope="session") def resource_owner_token( gn_auth_url, http, _admin_full_scope_token, oauth2_credentials, bxd_resource_id): """Bearer token for a resource-owner on the BXD population, provisioned once per session.""" *_, client_id, client_secret = oauth2_credentials admin_hdrs = {"Authorization": f"Bearer {_admin_full_scope_token}"} unique = str(uuid.uuid4())[:8] email = f"test-resource-owner-{unique}@regression.genenetwork.org" password = "GnTest1234!" create_resp = http.post( f"{gn_auth_url}/auth/system/administration/users/create", json={"email": email, "name": "Test resource-owner", "password": password}, headers=admin_hdrs, timeout=30, ) assert create_resp.status_code == 201, ( f"Failed to create resource-owner test user: " f"{create_resp.status_code} {create_resp.text}" ) user_id = create_resp.json()["user_id"] assign_resp = http.post( f"{gn_auth_url}/auth/system/administration/resources/{bxd_resource_id}/assign-owner", json={"user_id": user_id}, headers=admin_hdrs, timeout=30, ) assert assign_resp.status_code == 200, ( f"Failed to assign resource-owner to user {user_id} on {bxd_resource_id}: " f"{assign_resp.status_code} {assign_resp.text}" ) token_resp = http.post( f"{gn_auth_url}/auth/token", json={ "grant_type": "password", "username": email, "password": password, "scope": "profile group resource role user", "client_id": client_id, "client_secret": client_secret, }, timeout=30, ) assert token_resp.status_code == 200, ( f"Failed to get token for resource-owner test user: " f"{token_resp.status_code} {token_resp.text}" ) yield token_resp.json()["access_token"] http.post( f"{gn_auth_url}/auth/system/administration/resources/{bxd_resource_id}/revoke-owner", json={"user_id": user_id}, headers=admin_hdrs, timeout=30, ) http.post( f"{gn_auth_url}/auth/system/administration/users/delete", json={"user_ids": [user_id]}, headers=admin_hdrs, timeout=30, ) # --------------------------------------------------------------------------- # Level 4: edit with resource-owner privilege # --------------------------------------------------------------------------- @pytest.mark.case_attr_access @pytest.mark.auth_flow @pytest.mark.parametrize("path", [ f"/case-attribute/{_INBREDSET_ID}/edit", f"/v1/species/{_SPECIES_ID}/populations/{_INBREDSET_ID}/case-attributes/edit", ]) def test_resource_owner_can_edit_returns_201( gn3_url, http, resource_owner_token, path): """A resource-owner on the BXD population must be allowed to queue edits (201).""" resp = http.post( f"{gn3_url}{path}", json={"edit-data": []}, headers={"Authorization": f"Bearer {resource_owner_token}"}, timeout=30, ) assert resp.status_code == 201, ( f"Expected 201 for resource-owner POST {path!r}, " f"got {resp.status_code}. Body: {resp.text[:300]}" ) # --------------------------------------------------------------------------- # Level 5: approve/reject with systemwide-data-curator privilege # --------------------------------------------------------------------------- @pytest.mark.case_attr_access @pytest.mark.auth_flow @pytest.mark.parametrize("path", [ f"/case-attribute/{_INBREDSET_ID}/approve/{_CHANGE_ID}", f"/v1/species/{_SPECIES_ID}/populations/{_INBREDSET_ID}/case-attributes/diffs/{_CHANGE_ID}/approve", ]) def test_data_curator_can_approve_returns_200_or_201( gn3_url, http, data_curator_token, path): """A systemwide-data-curator must be allowed to approve diffs (200/201). The endpoint returns 201 when the change is successfully applied and 200 when there is no matching pending diff. Either response proves the auth check passed. """ resp = http.post( f"{gn3_url}{path}", headers={"Authorization": f"Bearer {data_curator_token}"}, timeout=30, ) assert resp.status_code in (200, 201), ( f"Expected 200 or 201 for systemwide-data-curator POST {path!r}, " f"got {resp.status_code}. Body: {resp.text[:300]}" ) @pytest.mark.case_attr_access @pytest.mark.auth_flow @pytest.mark.parametrize("path", [ f"/case-attribute/{_INBREDSET_ID}/reject/{_CHANGE_ID}", f"/v1/species/{_SPECIES_ID}/populations/{_INBREDSET_ID}/case-attributes/diffs/{_CHANGE_ID}/reject", ]) def test_data_curator_can_reject_returns_200_or_201( gn3_url, http, data_curator_token, path): """A systemwide-data-curator must be allowed to reject diffs (200/201). The endpoint returns 201 when the change is successfully rejected and 200 when there is no matching pending diff. Either response proves the auth check passed. """ resp = http.post( f"{gn3_url}{path}", headers={"Authorization": f"Bearer {data_curator_token}"}, timeout=30, ) assert resp.status_code in (200, 201), ( f"Expected 200 or 201 for systemwide-data-curator POST {path!r}, " f"got {resp.status_code}. Body: {resp.text[:300]}" )