import csv import json from contextlib import ExitStack from datetime import datetime, timedelta, timezone from http import HTTPStatus from io import StringIO from time import sleep import pytest from cvat_sdk.api_client import exceptions, models from cvat_sdk.api_client.api_client import ApiClient, Endpoint from deepdiff import DeepDiff from pytest_cases import parametrize from shared.utils.config import make_api_client from .utils import CollectionSimpleFilterTestBase, export_backup, export_dataset, export_events @pytest.mark.usefixtures("restore_db_per_function") class TestPostAccessToken: @pytest.mark.parametrize("user_group", ["admin", "user", "worker"]) def test_can_create_token(self, users, user_group): user = next(u for u in users if user_group in u["groups"]) with make_api_client(user["username"]) as api_client: token, _ = api_client.auth_api.create_access_tokens( access_token_write_request=models.AccessTokenWriteRequest(name="test token") ) assert token.value with make_api_client(access_token=token.value) as api_client: user, _ = api_client.users_api.retrieve_self() assert user.username == user["username"] def test_can_create_token_with_expiration(self, admin_user): expiration_date = datetime.now(tz=timezone.utc) - timedelta(seconds=1) with make_api_client(admin_user) as api_client: token, _ = api_client.auth_api.create_access_tokens( access_token_write_request=models.AccessTokenWriteRequest( name="test token", expiry_date=expiration_date ) ) assert token.expiry_date == expiration_date with ( make_api_client(access_token=token.value) as api_client, pytest.raises(exceptions.UnauthorizedException, match="Invalid token"), ): api_client.users_api.retrieve_self() @parametrize("is_readonly", [True, False]) def test_can_create_readonly_token(self, admin_user, is_readonly, tasks): with make_api_client(admin_user) as api_client: token, _ = api_client.auth_api.create_access_tokens( access_token_write_request=models.AccessTokenWriteRequest( name="test token", read_only=is_readonly ) ) assert token.read_only == is_readonly with make_api_client(access_token=token.value) as api_client: user, _ = api_client.users_api.retrieve_self() assert user.username == admin_user with ExitStack() as es: if is_readonly: es.enter_context(pytest.raises(exceptions.ForbiddenException)) api_client.tasks_api.partial_update( next(iter(tasks))["id"], patched_task_write_request=models.PatchedTaskWriteRequest(name="new name"), ) class TestGetAccessToken: def test_can_get_access_token(self, admin_user, access_tokens): expected = next(iter(access_tokens)) with make_api_client(admin_user) as api_client: _, response = api_client.auth_api.retrieve_access_tokens(expected["id"]) actual = json.loads(response.data) assert DeepDiff(expected, actual, exclude_paths=["private_key"]) == {} @parametrize("is_admin", [True, False]) def test_cannot_see_foreign_tokens(self, users, access_tokens_by_username, is_admin): token_owner, token_owner_tokens = next(iter(access_tokens_by_username.items())) token = token_owner_tokens[0] other_user = next( u for u in users if u["username"] != token_owner if u["is_superuser"] == is_admin if not access_tokens_by_username.get(u["username"]) ) with make_api_client(other_user["username"]) as api_client: _, response = api_client.auth_api.retrieve_access_tokens( token["id"], _check_status=False ) if is_admin: assert response.status == HTTPStatus.OK else: assert response.status == HTTPStatus.FORBIDDEN assert api_client.auth_api.list_access_tokens()[0].count == 0 def test_can_get_self(self, access_tokens_by_username): _, user_tokens = next(iter(access_tokens_by_username.items())) token = user_tokens[0] with make_api_client(access_token=token["private_key"]) as api_client: received_token = json.loads(api_client.auth_api.retrieve_access_tokens_self()[1].data) assert ( DeepDiff( token, received_token, exclude_paths=["updated_date", "last_used_date", "private_key"], ) == {} ) @pytest.mark.usefixtures("restore_db_per_function") @pytest.mark.parametrize("token_eol_reason", ["expired", "stale", "revoked"]) def test_can_only_see_alive_tokens(self, token_eol_reason: str, admin_user): with make_api_client(admin_user) as api_client: if token_eol_reason == "expired": token_id = api_client.auth_api.create_access_tokens( access_token_write_request=models.AccessTokenWriteRequest( name="test token", expiry_date=datetime.now(timezone.utc) - timedelta(seconds=1), ) )[0].id elif token_eol_reason == "revoked": token_id = api_client.auth_api.create_access_tokens( access_token_write_request=models.AccessTokenWriteRequest(name="test token") )[0].id api_client.auth_api.destroy_access_tokens(token_id) elif token_eol_reason == "stale": token_id = 6 else: assert False, f"Unexpected token eol reason '{token_eol_reason}'" with pytest.raises( exceptions.NotFoundException, match="No AccessToken matches the given query" ): api_client.auth_api.retrieve_access_tokens(token_id) assert ( api_client.auth_api.list_access_tokens( filter=json.dumps({"==": [{"var": "id"}, token_id]}) )[0].count == 0 ) class TestAccessTokenListFilters(CollectionSimpleFilterTestBase): @pytest.fixture(autouse=True) def setup(self, restore_db_per_class, users_by_name, raw_access_tokens_by_username): # Only own keys are visible to each user self.user, self.samples = next( (username, user_tokens) for username, user_tokens in raw_access_tokens_by_username.items() if users_by_name[username]["is_superuser"] and user_tokens ) def _get_endpoint(self, api_client: ApiClient) -> Endpoint: return api_client.auth_api.list_access_tokens_endpoint @pytest.mark.parametrize("field", ("name", "read_only")) def test_can_use_simple_filter_for_object_list(self, field): return super()._test_can_use_simple_filter_for_object_list(field) @pytest.mark.usefixtures("restore_db_per_function") class TestPatchAccessToken: def test_can_modify_token(self, access_tokens_by_username): user, user_tokens = next(iter(access_tokens_by_username.items())) token = user_tokens[0] updated_values = { "name": "new name", "expiry_date": datetime.now(timezone.utc) + timedelta(days=1), "read_only": not token["read_only"], } updated_token = dict(token) updated_token.update(updated_values) with make_api_client(user) as api_client: _, response = api_client.auth_api.partial_update_access_tokens( token["id"], patched_access_token_write_request=models.PatchedAccessTokenWriteRequest( **updated_values ), ) received_token = json.loads(response.data) updated_token["expiry_date"] = ( updated_token["expiry_date"].isoformat().replace("+00:00", "Z") ) assert ( DeepDiff(updated_token, received_token, exclude_paths=["updated_date", "private_key"]) == {} ) def test_cannot_modify_foreign_token(self, users, access_tokens_by_username): token_owner, token_owner_tokens = next(iter(access_tokens_by_username.items())) token = token_owner_tokens[0] other_user = next( u["username"] for u in users if u["username"] != token_owner and not u["is_superuser"] ) with ( make_api_client(other_user) as api_client, pytest.raises(exceptions.ForbiddenException), ): api_client.auth_api.partial_update_access_tokens( token["id"], patched_access_token_write_request=models.PatchedAccessTokenWriteRequest( name="new name" ), ) def test_cannot_modify_expired_token(self, admin_user): with make_api_client(admin_user) as api_client: token_id = api_client.auth_api.create_access_tokens( access_token_write_request=models.AccessTokenWriteRequest( name="test token", expiry_date=datetime.now(timezone.utc) - timedelta(seconds=1), ) )[0].id with pytest.raises( exceptions.NotFoundException, match="No AccessToken matches the given query" ): api_client.auth_api.partial_update_access_tokens( token_id, patched_access_token_write_request=models.PatchedAccessTokenWriteRequest( expiry_date=datetime.now(timezone.utc) + timedelta(days=1) ), ) @pytest.mark.usefixtures("restore_db_per_function") class TestDeleteAccessToken: def test_can_revoke_own_token(self, access_tokens_by_username): user, user_tokens = next(iter(access_tokens_by_username.items())) token = user_tokens[0] with make_api_client(user) as api_client: api_client.auth_api.destroy_access_tokens(token["id"]) def test_cannot_revoke_foreign_token(self, users, access_tokens_by_username): token_owner, token_owner_tokens = next(iter(access_tokens_by_username.items())) token = token_owner_tokens[0] other_user = next( u["username"] for u in users if u["username"] != token_owner and not u["is_superuser"] ) with ( make_api_client(other_user) as api_client, pytest.raises(exceptions.ForbiddenException), ): api_client.auth_api.destroy_access_tokens(token["id"]) class TestTokenAuthPermissions: # Some operations are not allowed with token auth for security reasons, even if not read only def test_cannot_change_password_when_using_token_auth( self, admin_user, access_tokens_by_username ): token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"]) with ( make_api_client(access_token=token["private_key"]) as api_client, pytest.raises(exceptions.UnauthorizedException), ): api_client.auth_api.create_password_change( password_change_request=models.PasswordChangeRequest( old_password="any", new_password1="anypass1", new_password2="anypass1" ) ) def test_cannot_create_token_when_using_token_auth(self, admin_user, access_tokens_by_username): token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"]) with ( make_api_client(access_token=token["private_key"]) as api_client, pytest.raises(exceptions.ForbiddenException), ): api_client.auth_api.create_access_tokens( access_token_write_request=models.AccessTokenWriteRequest(name="test token") ) def test_cannot_edit_token_when_using_token_auth(self, admin_user, access_tokens_by_username): token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"]) with ( make_api_client(access_token=token["private_key"]) as api_client, pytest.raises(exceptions.ForbiddenException), ): api_client.auth_api.partial_update_access_tokens( token["id"], patched_access_token_write_request=models.PatchedAccessTokenWriteRequest( name="new name" ), ) def test_cannot_revoke_token_when_using_token_auth(self, admin_user, access_tokens_by_username): token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"]) with ( make_api_client(access_token=token["private_key"]) as api_client, pytest.raises(exceptions.ForbiddenException), ): api_client.auth_api.destroy_access_tokens(token["id"]) def test_cannot_edit_user_when_using_token_auth( self, admin_user, access_tokens_by_username, users_by_name ): token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"]) user = users_by_name[admin_user] with ( make_api_client(access_token=token["private_key"]) as api_client, pytest.raises(exceptions.ForbiddenException), ): api_client.users_api.partial_update( user["id"], patched_user_request=models.PatchedUserRequest(first_name="new name") ) @pytest.mark.usefixtures("restore_redis_ondisk_per_function") @parametrize("is_readonly", [True, False]) @parametrize("export_type", ["annotations", "dataset", "backup"]) def test_token_can_export_project( self, admin_user, access_tokens_by_username, projects, export_type, is_readonly ): token = next( t for t in access_tokens_by_username[admin_user] if t["read_only"] == is_readonly ) project_id = next(p["id"] for p in projects if p["tasks"]["count"] > 0) with make_api_client(access_token=token["private_key"]) as api_client: if export_type in ("annotations", "dataset"): export_dataset( api_client.projects_api, id=project_id, save_images=(export_type == "dataset") ) elif export_type == "backup": export_backup(api_client.projects_api, id=project_id) @pytest.mark.usefixtures("restore_redis_ondisk_per_function") @parametrize("is_readonly", [True, False]) @parametrize("export_type", ["annotations", "dataset", "backup"]) def test_token_can_export_task( self, admin_user, access_tokens_by_username, tasks, export_type, is_readonly ): token = next( t for t in access_tokens_by_username[admin_user] if t["read_only"] == is_readonly ) task_id = next(p["id"] for p in tasks if p["size"] > 0 if p["media_type"] != "audio") with make_api_client(access_token=token["private_key"]) as api_client: if export_type in ("annotations", "dataset"): export_dataset( api_client.tasks_api, id=task_id, save_images=(export_type == "dataset") ) elif export_type == "backup": export_backup(api_client.tasks_api, id=task_id) @pytest.mark.usefixtures("restore_redis_ondisk_per_function") @parametrize("is_readonly", [True, False]) @parametrize("export_type", ["annotations", "dataset"]) def test_token_can_export_jobs( self, admin_user, access_tokens_by_username, jobs, export_type, is_readonly ): token = next( t for t in access_tokens_by_username[admin_user] if t["read_only"] == is_readonly ) job_id = next(p["id"] for p in jobs if p["media_type"] != "audio") with make_api_client(access_token=token["private_key"]) as api_client: if export_type in ("annotations", "dataset"): export_dataset( api_client.jobs_api, id=job_id, save_images=(export_type == "dataset") ) elif export_type == "backup": export_backup(api_client.jobs_api, id=job_id) @pytest.mark.usefixtures("restore_db_per_function") class TestTokenTracking: def test_can_update_last_use(self, access_tokens_by_username): _, user_tokens = next(iter(access_tokens_by_username.items())) token = user_tokens[0] with make_api_client(access_token=token["private_key"]) as api_client: updated_token, _ = api_client.auth_api.retrieve_access_tokens_self() old_last_used_date = token["last_used_date"] if old_last_used_date is not None: old_last_used_date = datetime.fromisoformat( token["last_used_date"].rstrip("Z") ).replace(tzinfo=timezone.utc) assert updated_token.last_used_date != old_last_used_date @pytest.mark.usefixtures("restore_redis_inmem_per_function") def test_can_record_token_events_in_audit_logs(self, admin_user, tasks): test_start_date = datetime.now(tz=timezone.utc) with make_api_client(admin_user) as api_client: token = api_client.auth_api.create_access_tokens( access_token_write_request=models.AccessTokenWriteRequest( name="test token", read_only=False ) )[0] task_id = next(p["id"] for p in tasks if p["size"] > 0) with make_api_client(access_token=token.value) as api_client: api_client.tasks_api.partial_update( task_id, patched_task_write_request=models.PatchedTaskWriteRequest(name="newname") ) with make_api_client(admin_user) as api_client: api_client.auth_api.partial_update_access_tokens( token.id, patched_access_token_write_request=models.PatchedAccessTokenWriteRequest( expiry_date=test_start_date + timedelta(days=1) ), ) api_client.auth_api.destroy_access_tokens(token.id) # Clickhouse updates are not immediate, vector has buffering on sinks. # All these checks are in a single test because of this extra waiting. # Potentially unstable, should probably be improved or maybe moved into server tests. sleep(5) events_csv = export_events(api_client, api_version=2, _from=test_start_date) csv_reader = csv.DictReader(StringIO(events_csv.decode())) rows = list(csv_reader) def find_row(scope: str): return next((i, r) for i, r in enumerate(rows) if r["scope"] == scope) token_creation_row_index, row = find_row(scope="create:accesstoken") assert row["obj_id"] == str(token.id) task_update_row_index, row = find_row(scope="update:task") assert row["task_id"] == str(task_id) assert row["access_token_id"] == str(token.id) assert row["obj_name"] == "name" assert row["obj_val"] == "newname" token_update_row_index, row = find_row(scope="update:accesstoken") # token id is not present in the "update:*" event fields, can't check it assert row["obj_name"] == "expiry_date" token_delete_row_index, row = find_row(scope="delete:accesstoken") assert row["obj_id"] == str(token.id) assert token_creation_row_index < task_update_row_index assert task_update_row_index < token_update_row_index assert token_update_row_index < token_delete_row_index