blob: ef1dea5e3754c24e742c9736bd21f468e28919b6 [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"})
@pytest.mark.asyncio
async def test_email_uid_read_ignores_empty_disk_cache(state_dir: pathlib.Path):
cache_path = state_dir / "secrets" / "cached" / "email_uid.json"
cache_path.parent.mkdir(parents=True)
cache_data = cache.EmailUidCache(
refreshed=datetime.datetime.now(datetime.UTC),
hashes={},
reverse={},
)
cache_path.write_text(cache_data.model_dump_json())
assert cache._email_uid_read_from_file() is None
assert not cache_path.exists()
assert await cache._email_uid_read_from_file_async() is None
@pytest.mark.asyncio
async def test_email_uid_refresh_refuses_empty_ldap_mapping(
state_dir: pathlib.Path, mock_app: MockApp, monkeypatch: "MonkeyPatch"
):
async def email_to_uid_map() -> dict[str, str]:
return {}
monkeypatch.setattr("atr.util.email_to_uid_map", email_to_uid_map)
with pytest.raises(RuntimeError, match="no usable LDAP email mappings"):
await cache.email_uid_refresh()
cache_path = state_dir / "secrets" / "cached" / "email_uid.json"
assert not cache_path.exists()
assert "email_uid_hashes" not in mock_app.extensions
assert "email_uid_reverse" not in mock_app.extensions
@pytest.mark.asyncio
async def test_email_uid_startup_load_refreshes_ldap_instead_of_reading_disk(
state_dir: pathlib.Path, mock_app: MockApp, monkeypatch: "MonkeyPatch"
):
old_hash = cache._email_uid_hash("old@example.org")
new_hash = cache._email_uid_hash("new@example.org")
async def email_to_uid_map() -> dict[str, str]:
return {"new@example.org": "new"}
monkeypatch.setattr("atr.util.email_to_uid_map", email_to_uid_map)
await cache.email_uid_save_to_file({old_hash: "old"}, {"old": [old_hash]})
await cache.email_uid_startup_load()
assert mock_app.extensions["email_uid_hashes"] == {new_hash: "new"}
assert mock_app.extensions["email_uid_reverse"] == {"new": [new_hash]}
@pytest.mark.asyncio
async def test_email_uid_view_reloads_changed_disk_cache(state_dir: pathlib.Path, mock_app: MockApp):
old_hash = cache._email_uid_hash("old@example.org")
new_hash = cache._email_uid_hash("new@example.org")
mock_app.extensions["email_uid_hashes"] = {old_hash: "old"}
mock_app.extensions["email_uid_reverse"] = {"old": [old_hash]}
mock_app.extensions["email_uid_file_mtime_ns"] = 0
await cache.email_uid_save_to_file({new_hash: "new"}, {"new": [new_hash]})
lookup = cache.email_uid_view()
assert lookup.get("new@example.org") == "new"
assert lookup.get("old@example.org") is None
assert mock_app.extensions["email_uid_hashes"] == {new_hash: "new"}