blob: a1657783a0e8bb9b096dbb13d36dbddad684bf78 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import datetime
import json
import pathlib
from typing import TYPE_CHECKING
import pydantic
import pytest
import atr.cache as cache
if TYPE_CHECKING:
from pytest import MonkeyPatch
class MockApp:
def __init__(self):
self.extensions: dict[str, object] = {}
class MockConfig:
def __init__(self, state_dir: pathlib.Path):
self.STATE_DIR = str(state_dir)
@pytest.fixture
def mock_app(monkeypatch: "MonkeyPatch") -> MockApp:
app = MockApp()
monkeypatch.setattr("asfquart.APP", app)
return app
@pytest.fixture
def state_dir(tmp_path: pathlib.Path, monkeypatch: "MonkeyPatch") -> pathlib.Path:
monkeypatch.setattr("atr.config.get", lambda: MockConfig(tmp_path))
return tmp_path
def test_admins_cache_rejects_missing_admins():
with pytest.raises(pydantic.ValidationError):
cache.AdminsCache.model_validate({"refreshed": "2025-01-01T00:00:00Z"})
def test_admins_cache_rejects_missing_refreshed():
with pytest.raises(pydantic.ValidationError):
cache.AdminsCache.model_validate({"admins": ["alice"]})
def test_admins_cache_roundtrip_json():
original = cache.AdminsCache(
refreshed=datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.UTC),
admins=frozenset({"alice", "bob", "charlie"}),
)
json_str = original.model_dump_json()
restored = cache.AdminsCache.model_validate_json(json_str)
assert restored.refreshed == original.refreshed
assert restored.admins == original.admins
def test_admins_cache_serializes_to_json():
data = cache.AdminsCache(
refreshed=datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.UTC),
admins=frozenset({"alice", "bob"}),
)
json_str = data.model_dump_json()
parsed = json.loads(json_str)
assert "refreshed" in parsed
assert "admins" in parsed
assert set(parsed["admins"]) == {"alice", "bob"}
def test_admins_cache_validates_with_good_data():
data = cache.AdminsCache(
refreshed=datetime.datetime.now(datetime.UTC),
admins=frozenset({"alice", "bob"}),
)
assert isinstance(data.refreshed, datetime.datetime)
assert data.admins == frozenset({"alice", "bob"})
@pytest.mark.asyncio
async def test_admins_get_async_falls_back_to_file_without_app_context(
state_dir: pathlib.Path, monkeypatch: "MonkeyPatch"
):
def raise_runtime_error() -> frozenset[str]:
raise RuntimeError("No app context")
monkeypatch.setattr("atr.cache.admins_get", raise_runtime_error)
await cache.admins_save_to_file(frozenset({"file_alice", "file_bob"}))
result = await cache.admins_get_async()
assert result == frozenset({"file_alice", "file_bob"})
@pytest.mark.asyncio
async def test_admins_get_async_uses_extensions_when_available(mock_app: MockApp):
mock_app.extensions["admins"] = frozenset({"async_alice"})
result = await cache.admins_get_async()
assert result == frozenset({"async_alice"})
def test_admins_get_returns_empty_frozenset_when_not_set(mock_app: MockApp):
result = cache.admins_get()
assert result == frozenset()
def test_admins_get_returns_frozenset_from_extensions(mock_app: MockApp):
mock_app.extensions["admins"] = frozenset({"alice", "bob"})
result = cache.admins_get()
assert result == frozenset({"alice", "bob"})
@pytest.mark.asyncio
async def test_admins_read_from_file_returns_none_for_invalid_json(state_dir: pathlib.Path):
cache_path = state_dir / "cache" / "admins.json"
cache_path.parent.mkdir(parents=True)
cache_path.write_text("not valid json {{{")
result = await cache._admins_read_from_file_async()
assert result is None
@pytest.mark.asyncio
async def test_admins_read_from_file_returns_none_for_invalid_schema(state_dir: pathlib.Path):
cache_path = state_dir / "cache" / "admins.json"
cache_path.parent.mkdir(parents=True)
cache_path.write_text('{"wrong_field": "value"}')
result = await cache._admins_read_from_file_async()
assert result is None
@pytest.mark.asyncio
async def test_admins_read_from_file_returns_none_for_missing(state_dir: pathlib.Path):
result = await cache._admins_read_from_file_async()
assert result is None
@pytest.mark.asyncio
async def test_admins_save_and_read_roundtrip(state_dir: pathlib.Path):
original_admins = frozenset({"alice", "bob", "charlie"})
await cache.admins_save_to_file(original_admins)
result = await cache._admins_read_from_file_async()
assert result is not None
assert result.admins == original_admins
@pytest.mark.asyncio
async def test_admins_save_to_file_creates_file(state_dir: pathlib.Path):
admins = frozenset({"alice", "bob"})
await cache.admins_save_to_file(admins)
cache_path = state_dir / "cache" / "admins.json"
assert cache_path.exists()
@pytest.mark.asyncio
async def test_admins_save_to_file_creates_parent_dirs(state_dir: pathlib.Path):
cache_dir = state_dir / "cache"
assert not cache_dir.exists()
await cache.admins_save_to_file(frozenset({"alice"}))
assert cache_dir.exists()
assert cache_dir.is_dir()
@pytest.mark.asyncio
async def test_admins_startup_load_calls_ldap_when_cache_missing(
state_dir: pathlib.Path, mock_app: MockApp, monkeypatch: "MonkeyPatch"
):
ldap_called = False
async def mock_fetch_admin_users() -> frozenset[str]:
nonlocal ldap_called
ldap_called = True
return frozenset({"ldap_alice", "ldap_bob"})
monkeypatch.setattr("atr.ldap.fetch_admin_users", mock_fetch_admin_users)
await cache.admins_startup_load()
assert ldap_called is True
assert mock_app.extensions["admins"] == frozenset({"ldap_alice", "ldap_bob"})
cache_path = state_dir / "cache" / "admins.json"
assert cache_path.exists()
@pytest.mark.asyncio
async def test_admins_startup_load_handles_ldap_failure(
state_dir: pathlib.Path, mock_app: MockApp, monkeypatch: "MonkeyPatch"
):
async def mock_fetch_admin_users() -> frozenset[str]:
raise ConnectionError("LDAP server unavailable")
monkeypatch.setattr("atr.ldap.fetch_admin_users", mock_fetch_admin_users)
await cache.admins_startup_load()
assert "admins" not in mock_app.extensions
cache_path = state_dir / "cache" / "admins.json"
assert not cache_path.exists()
@pytest.mark.asyncio
async def test_admins_startup_load_uses_cache_when_present(
state_dir: pathlib.Path, mock_app: MockApp, monkeypatch: "MonkeyPatch"
):
ldap_called = False
async def mock_fetch_admin_users() -> frozenset[str]:
nonlocal ldap_called
ldap_called = True
return frozenset({"from_ldap"})
monkeypatch.setattr("atr.ldap.fetch_admin_users", mock_fetch_admin_users)
await cache.admins_save_to_file(frozenset({"cached_alice", "cached_bob"}))
await cache.admins_startup_load()
assert ldap_called is False
assert mock_app.extensions["admins"] == frozenset({"cached_alice", "cached_bob"})