Files
2026-07-13 13:32:23 +08:00

481 lines
20 KiB
Python

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