6db56e5d2e
<!-- Raise an issue to propose your change (https://github.com/cvat-ai/cvat/issues). It helps to avoid duplication of efforts from multiple independent contributors. Discuss your ideas with maintainers to be sure that changes will be approved and merged. Read the [Contribution guide](https://docs.cvat.ai/docs/contributing/). --> <!-- Provide a general summary of your changes in the Title above --> ### Motivation and context <!-- Why is this change required? What problem does it solve? If it fixes an open issue, please link to the issue here. Describe your changes in detail, add screenshots. --> ### How has this been tested? <!-- Please describe in detail how you tested your changes. Include details of your testing environment, and the tests you ran to see how your change affects other areas of the code, etc. --> ### Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. If an item isn't applicable for some reason, then ~~explicitly strikethrough~~ the whole line. If you don't do that, GitHub will show incorrect progress for the pull request. If you're unsure about any of these, don't hesitate to ask. We're here to help! --> - [x] I submit my changes into the `develop` branch - [x] I have created a changelog fragment <!-- see top comment in CHANGELOG.md --> - [ ] I have updated the documentation accordingly - [x] I have added tests to cover my changes - [x] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. --------- Co-authored-by: Maxim Zhiltsov <maxim@cvat.ai>
225 lines
6.8 KiB
Python
225 lines
6.8 KiB
Python
# Copyright (C) CVAT.ai Corporation
|
|
#
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import platformdirs
|
|
import pytest
|
|
from cvat_sdk.core.auth import (
|
|
AuthStore,
|
|
AuthStoreError,
|
|
ProfileEntry,
|
|
get_auth_store_path,
|
|
)
|
|
from cvat_sdk.core.utils import is_posix
|
|
|
|
|
|
def test_auth_store_path_matches_platformdirs():
|
|
expected = platformdirs.user_config_path("cvat-sdk", "CVAT.ai") / "auth.json"
|
|
assert get_auth_store_path() == expected
|
|
assert isinstance(get_auth_store_path(), Path)
|
|
|
|
|
|
def _store(tmp_path) -> AuthStore:
|
|
return AuthStore(path=tmp_path / "cvat" / "auth.json")
|
|
|
|
|
|
def test_load_returns_empty_doc_when_file_absent(tmp_path):
|
|
doc = _store(tmp_path)._load()
|
|
assert doc == {"version": 1, "profiles": {}}
|
|
|
|
|
|
def test_save_then_load_roundtrips(tmp_path):
|
|
store = _store(tmp_path)
|
|
store._save({"version": 1, "profiles": {"a": {"server": "https://x", "token": "t"}}})
|
|
assert store._load()["profiles"]["a"]["token"] == "t"
|
|
|
|
|
|
@pytest.mark.skipif(not is_posix(), reason="POSIX permission semantics")
|
|
def test_save_creates_0600_file_in_0700_dir(tmp_path):
|
|
store = _store(tmp_path)
|
|
store._save({"version": 1, "profiles": {}})
|
|
path = tmp_path / "cvat" / "auth.json"
|
|
assert (path.stat().st_mode & 0o777) == 0o600
|
|
assert (path.parent.stat().st_mode & 0o777) == 0o700
|
|
|
|
|
|
@pytest.mark.skipif(not is_posix(), reason="POSIX permission semantics")
|
|
def test_load_refuses_world_readable_file(tmp_path):
|
|
store = _store(tmp_path)
|
|
store._save({"version": 1, "profiles": {}})
|
|
path = tmp_path / "cvat" / "auth.json"
|
|
os.chmod(path, 0o644)
|
|
with pytest.raises(AuthStoreError, match="permission"):
|
|
AuthStore(path=path)._load()
|
|
|
|
|
|
@pytest.mark.skipif(not is_posix(), reason="POSIX permission semantics")
|
|
def test_load_allows_file_with_secure_base_permissions_and_special_bits(tmp_path):
|
|
store = _store(tmp_path)
|
|
store._save({"version": 1, "profiles": {}})
|
|
path = tmp_path / "cvat" / "auth.json"
|
|
# 0o1600 is 0o600 plus the POSIX sticky bit; only the base permission bits matter.
|
|
os.chmod(path, 0o1600)
|
|
assert AuthStore(path=path)._load() == {"version": 1, "profiles": {}}
|
|
|
|
|
|
def test_load_rejects_directory_path(tmp_path):
|
|
path = tmp_path / "cvat" / "auth.json"
|
|
path.mkdir(parents=True)
|
|
if is_posix():
|
|
os.chmod(path.parent, 0o700)
|
|
os.chmod(path, 0o700)
|
|
with pytest.raises(AuthStoreError, match="must be a file"):
|
|
_store(tmp_path)._load()
|
|
|
|
|
|
def test_load_rejects_unknown_version(tmp_path):
|
|
path = tmp_path / "cvat" / "auth.json"
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(json.dumps({"version": 999, "profiles": {}}))
|
|
if is_posix():
|
|
os.chmod(path.parent, 0o700)
|
|
os.chmod(path, 0o600)
|
|
with pytest.raises(AuthStoreError, match="version"):
|
|
_store(tmp_path)._load()
|
|
|
|
|
|
def test_load_rejects_corrupt_json(tmp_path):
|
|
path = tmp_path / "cvat" / "auth.json"
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text("{not json")
|
|
if is_posix():
|
|
os.chmod(path.parent, 0o700)
|
|
os.chmod(path, 0o600)
|
|
with pytest.raises(AuthStoreError):
|
|
_store(tmp_path)._load()
|
|
|
|
|
|
def _entry(server="https://app.cvat.ai", token="tok") -> ProfileEntry:
|
|
return ProfileEntry(server=server, token=token, created_date="2026-01-01T00:00:00+00:00")
|
|
|
|
|
|
def test_put_get_list_remove_profile(tmp_path):
|
|
store = _store(tmp_path)
|
|
assert store.list_profiles() == {}
|
|
store.put_profile("mycvat", _entry())
|
|
assert store.get_profile("mycvat") == _entry()
|
|
assert set(store.list_profiles()) == {"mycvat"}
|
|
store.remove_profile("mycvat")
|
|
assert store.get_profile("mycvat") is None
|
|
|
|
|
|
def test_failed_write_does_not_pollute_cached_doc(tmp_path, monkeypatch):
|
|
store = _store(tmp_path)
|
|
store.put_profile("kept", _entry())
|
|
|
|
def fail_save(_doc):
|
|
raise AuthStoreError("save failed")
|
|
|
|
monkeypatch.setattr(store, "_save", fail_save)
|
|
|
|
with pytest.raises(AuthStoreError, match="save failed"):
|
|
store.put_profile("ghost", _entry(token="ghost"))
|
|
|
|
assert set(store.list_profiles()) == {"kept"}
|
|
assert store.get_profile("ghost") is None
|
|
|
|
|
|
def test_auth_store_reuses_loaded_doc(tmp_path, monkeypatch):
|
|
store = _store(tmp_path)
|
|
store._save(
|
|
{
|
|
"version": 1,
|
|
"profiles": {
|
|
"mycvat": {
|
|
"server": "https://x",
|
|
"token": "t",
|
|
"created_date": "2026-01-01T00:00:00+00:00",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
store = _store(tmp_path)
|
|
read_count = 0
|
|
original_read_text = Path.read_text
|
|
|
|
def read_text(self, *args, **kwargs):
|
|
nonlocal read_count
|
|
read_count += 1
|
|
return original_read_text(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "read_text", read_text)
|
|
|
|
assert store.get_profile("mycvat") is not None
|
|
assert set(store.list_profiles()) == {"mycvat"}
|
|
assert read_count == 1
|
|
|
|
|
|
def test_first_profile_becomes_default_when_requested(tmp_path):
|
|
store = _store(tmp_path)
|
|
store.put_profile("mycvat", _entry(), set_default=True)
|
|
name, entry = store.get_default_profile()
|
|
assert name == "mycvat"
|
|
assert entry == _entry()
|
|
|
|
|
|
def test_first_profile_becomes_default_even_without_flag(tmp_path):
|
|
store = _store(tmp_path)
|
|
store.put_profile("mycvat", _entry())
|
|
assert store.get_default_profile()[0] == "mycvat"
|
|
|
|
|
|
def test_put_profile_after_clear_default_does_not_recreate_default(tmp_path):
|
|
store = _store(tmp_path)
|
|
store.put_profile("first", _entry())
|
|
|
|
store.clear_default_profile()
|
|
store.put_profile("second", _entry(token="second"))
|
|
|
|
assert store.get_default_profile() is None
|
|
|
|
|
|
def test_set_default_profile_requires_existing(tmp_path):
|
|
store = _store(tmp_path)
|
|
with pytest.raises(KeyError):
|
|
store.set_default_profile("nope")
|
|
|
|
|
|
def test_removing_default_profile_clears_default(tmp_path):
|
|
store = _store(tmp_path)
|
|
store.put_profile("mycvat", _entry(), set_default=True)
|
|
store.remove_profile("mycvat")
|
|
assert store.get_default_profile() is None
|
|
|
|
|
|
def test_put_profile_after_removing_default_with_profiles_remaining_does_not_recreate_default(
|
|
tmp_path,
|
|
):
|
|
store = _store(tmp_path)
|
|
store.put_profile("first", _entry())
|
|
store.put_profile("second", _entry(token="second"))
|
|
|
|
store.remove_profile("first")
|
|
store.put_profile("third", _entry(token="third"))
|
|
|
|
assert store.get_default_profile() is None
|
|
|
|
|
|
def test_default_server_set_get_clear(tmp_path):
|
|
store = _store(tmp_path)
|
|
assert store.get_default_server() is None
|
|
store.set_default_server("https://staging.example.com")
|
|
assert store.get_default_server() == "https://staging.example.com"
|
|
store.clear_default_server()
|
|
assert store.get_default_server() is None
|
|
|
|
|
|
def test_remove_unknown_profile_raises(tmp_path):
|
|
with pytest.raises(KeyError):
|
|
_store(tmp_path).remove_profile("ghost")
|