fred-core 2.0.1__tar.gz → 2.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fred_core-2.0.1 → fred_core-2.0.2}/PKG-INFO +1 -1
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/oidc.py +8 -4
- fred_core-2.0.2/fred_core/tests/common/test_lru_cache.py +127 -0
- fred_core-2.0.2/fred_core/tests/kpi/test_noop_kpi_writer.py +113 -0
- fred_core-2.0.2/fred_core/tests/logs/test_memory_log_store.py +287 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/security/test_authorization.py +44 -1
- fred_core-2.0.2/fred_core/tests/security/test_whitelist_access_control.py +130 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/PKG-INFO +1 -1
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/SOURCES.txt +4 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/pyproject.toml +34 -1
- {fred_core-2.0.1 → fred_core-2.0.2}/README.md +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/cli/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/cli/auth.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/cli/ui.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/config_files.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/config_loader.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/env.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/fastapi_handlers.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/lru_cache.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/structures.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/team_id.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/utils.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/filesystem/local_filesystem.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/filesystem/minio_filesystem.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/filesystem/structures.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/base_history_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/history_models.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/history_schema.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/postgres_history_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/base_kpi_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/base_kpi_writer.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_phase_metric.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_process.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_reader_structures.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_writer.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_writer_structures.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/log_kpi_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/noop_kpi_writer.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/opensearch_kpi_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/prometheus_kpi_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/base_log_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/log_setup.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/log_structures.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/memory_log_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/null_log_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/opensearch_log_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/model/factory.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/model/http_clients.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/model/models.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/models/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/models/base.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/portable/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/portable/observability.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/py.typed +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/scheduler/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/scheduler/backend.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/scheduler/scheduler_structures.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/scheduler/temporal_client_provider.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/authorization.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/authorization_decorator.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/backend_to_backend_auth.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/keycloak/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/keycloak/keycloack_admin_client.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/models.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/outbound.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rbac.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/noop_engine.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/openfga_engine.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/openfga_schema.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/rebac_engine.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/rebac_factory.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/schema.fga +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/schema.fga.json +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/structure.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/whitelist_access_control/access_control.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/session_schema.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/stores/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/stores/base_session_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/stores/postgres_session_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/stores/session_models.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/alembic_env.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/async_session.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/base_sql.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/mixin.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/base_content_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/local_content_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/minio_content_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/opensearch_mapping_validator.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/vector_search.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/cli/test_auth.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/cli/test_ui.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/common/test_config_loader.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/common/test_env.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/common/test_log_setup.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/integration/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/integration/test_rebac.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/model/test_embedding_factory.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/model/test_vertex_model_garden_auth.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/scheduler/test_backend.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/security/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/security/test_rebac_engine_team_helpers.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/session/test_postgres_json_session_store_sqlite.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/test_log_kpi_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/test_prometheus_kpi_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/store/__init__.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/store/base_user_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/store/postgres_user_store.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/user_models.py +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/dependency_links.txt +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/requires.txt +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/top_level.txt +0 -0
- {fred_core-2.0.1 → fred_core-2.0.2}/setup.cfg +0 -0
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
15
|
import base64
|
|
16
|
+
import getpass
|
|
16
17
|
import json
|
|
17
18
|
import logging
|
|
18
19
|
import os
|
|
@@ -246,12 +247,15 @@ def _parse_user_uuid(user: KeycloakUser) -> UUID | None:
|
|
|
246
247
|
def decode_jwt(token: str) -> KeycloakUser:
|
|
247
248
|
"""Decodes a JWT token using PyJWT and retrieves user information with rich diagnostics."""
|
|
248
249
|
if not KEYCLOAK_ENABLED:
|
|
249
|
-
|
|
250
|
+
username = getpass.getuser()
|
|
251
|
+
logger.debug(
|
|
252
|
+
"[SECURITY] Authentication is DISABLED. Returning mock user: %s", username
|
|
253
|
+
)
|
|
250
254
|
return KeycloakUser(
|
|
251
|
-
uid=
|
|
252
|
-
username=
|
|
255
|
+
uid=username,
|
|
256
|
+
username=username,
|
|
253
257
|
roles=["admin"],
|
|
254
|
-
email="
|
|
258
|
+
email=f"{username}@localhost",
|
|
255
259
|
groups=["admins"],
|
|
256
260
|
)
|
|
257
261
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline unit tests for fred_core.common.lru_cache.ThreadSafeLRUCache.
|
|
3
|
+
|
|
4
|
+
Covers get/set/delete/keys/clear/__contains__ and the LRU eviction policy.
|
|
5
|
+
Thread-safety smoke: concurrent writers must not corrupt state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
from fred_core.common.lru_cache import ThreadSafeLRUCache
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestThreadSafeLRUCacheBasicOps:
|
|
16
|
+
def test_get_missing_returns_none(self) -> None:
|
|
17
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
18
|
+
assert cache.get("missing") is None
|
|
19
|
+
|
|
20
|
+
def test_set_and_get(self) -> None:
|
|
21
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
22
|
+
cache.set("k", 42)
|
|
23
|
+
assert cache.get("k") == 42
|
|
24
|
+
|
|
25
|
+
def test_overwrite_key(self) -> None:
|
|
26
|
+
cache: ThreadSafeLRUCache[str, str] = ThreadSafeLRUCache()
|
|
27
|
+
cache.set("k", "first")
|
|
28
|
+
cache.set("k", "second")
|
|
29
|
+
assert cache.get("k") == "second"
|
|
30
|
+
|
|
31
|
+
def test_delete_existing_returns_value(self) -> None:
|
|
32
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
33
|
+
cache.set("k", 99)
|
|
34
|
+
assert cache.delete("k") == 99
|
|
35
|
+
assert cache.get("k") is None
|
|
36
|
+
|
|
37
|
+
def test_delete_missing_returns_none(self) -> None:
|
|
38
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
39
|
+
assert cache.delete("ghost") is None
|
|
40
|
+
|
|
41
|
+
def test_keys_empty(self) -> None:
|
|
42
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
43
|
+
assert cache.keys() == []
|
|
44
|
+
|
|
45
|
+
def test_keys_returns_all_inserted(self) -> None:
|
|
46
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
47
|
+
cache.set("a", 1)
|
|
48
|
+
cache.set("b", 2)
|
|
49
|
+
assert set(cache.keys()) == {"a", "b"}
|
|
50
|
+
|
|
51
|
+
def test_keys_excludes_deleted(self) -> None:
|
|
52
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
53
|
+
cache.set("a", 1)
|
|
54
|
+
cache.set("b", 2)
|
|
55
|
+
cache.delete("a")
|
|
56
|
+
assert cache.keys() == ["b"]
|
|
57
|
+
|
|
58
|
+
def test_contains_present(self) -> None:
|
|
59
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
60
|
+
cache.set("k", 1)
|
|
61
|
+
assert "k" in cache
|
|
62
|
+
|
|
63
|
+
def test_contains_absent(self) -> None:
|
|
64
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
65
|
+
assert "ghost" not in cache
|
|
66
|
+
|
|
67
|
+
def test_clear_removes_all(self) -> None:
|
|
68
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache()
|
|
69
|
+
cache.set("a", 1)
|
|
70
|
+
cache.set("b", 2)
|
|
71
|
+
cache.clear()
|
|
72
|
+
assert cache.keys() == []
|
|
73
|
+
assert cache.get("a") is None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestThreadSafeLRUCacheEviction:
|
|
77
|
+
def test_lru_evicts_oldest_when_full(self) -> None:
|
|
78
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache(max_size=3)
|
|
79
|
+
cache.set("a", 1)
|
|
80
|
+
cache.set("b", 2)
|
|
81
|
+
cache.set("c", 3)
|
|
82
|
+
cache.set("d", 4) # should evict "a"
|
|
83
|
+
assert cache.get("a") is None
|
|
84
|
+
assert cache.get("b") == 2
|
|
85
|
+
assert cache.get("c") == 3
|
|
86
|
+
assert cache.get("d") == 4
|
|
87
|
+
|
|
88
|
+
def test_get_promotes_to_recent(self) -> None:
|
|
89
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache(max_size=3)
|
|
90
|
+
cache.set("a", 1)
|
|
91
|
+
cache.set("b", 2)
|
|
92
|
+
cache.set("c", 3)
|
|
93
|
+
cache.get("a") # promote "a" — "b" is now the least recently used
|
|
94
|
+
cache.set("d", 4) # should evict "b"
|
|
95
|
+
assert cache.get("b") is None
|
|
96
|
+
assert cache.get("a") == 1
|
|
97
|
+
|
|
98
|
+
def test_size_one_cache(self) -> None:
|
|
99
|
+
cache: ThreadSafeLRUCache[str, int] = ThreadSafeLRUCache(max_size=1)
|
|
100
|
+
cache.set("first", 1)
|
|
101
|
+
cache.set("second", 2)
|
|
102
|
+
assert cache.get("first") is None
|
|
103
|
+
assert cache.get("second") == 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestThreadSafeLRUCacheConcurrency:
|
|
107
|
+
def test_concurrent_writes_do_not_corrupt(self) -> None:
|
|
108
|
+
cache: ThreadSafeLRUCache[int, int] = ThreadSafeLRUCache(max_size=500)
|
|
109
|
+
errors: list[Exception] = []
|
|
110
|
+
|
|
111
|
+
def writer(start: int) -> None:
|
|
112
|
+
try:
|
|
113
|
+
for i in range(start, start + 100):
|
|
114
|
+
cache.set(i, i * 2)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
errors.append(exc)
|
|
117
|
+
|
|
118
|
+
threads = [threading.Thread(target=writer, args=(i * 100,)) for i in range(5)]
|
|
119
|
+
for t in threads:
|
|
120
|
+
t.start()
|
|
121
|
+
for t in threads:
|
|
122
|
+
t.join()
|
|
123
|
+
|
|
124
|
+
assert not errors
|
|
125
|
+
# All written keys should either be present or have been evicted by LRU
|
|
126
|
+
for key in cache.keys():
|
|
127
|
+
assert cache.get(key) == key * 2
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline unit tests for fred_core.kpi.noop_kpi_writer.NoOpKPIWriter.
|
|
3
|
+
|
|
4
|
+
The NoOp writer is used in every unit and integration test that instruments
|
|
5
|
+
code with the KPI API. If its contract is broken (timer doesn't yield a
|
|
6
|
+
mutable Dims dict, timed() fails to call the function, etc.) every caller
|
|
7
|
+
silently misbehaves.
|
|
8
|
+
|
|
9
|
+
Focus: verify the contract is honoured, not that operations do nothing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from fred_core.kpi.kpi_writer_structures import KPIActor
|
|
15
|
+
from fred_core.kpi.noop_kpi_writer import NoOpKPIWriter
|
|
16
|
+
|
|
17
|
+
ACTOR = KPIActor(type="system")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestNoOpKPIWriterEmitPrimitives:
|
|
21
|
+
def setup_method(self) -> None:
|
|
22
|
+
self.writer = NoOpKPIWriter()
|
|
23
|
+
|
|
24
|
+
def test_emit_does_not_raise(self) -> None:
|
|
25
|
+
self.writer.emit(name="test.metric", type="counter", actor=ACTOR)
|
|
26
|
+
|
|
27
|
+
def test_count_does_not_raise(self) -> None:
|
|
28
|
+
self.writer.count("test.count", actor=ACTOR)
|
|
29
|
+
|
|
30
|
+
def test_gauge_does_not_raise(self) -> None:
|
|
31
|
+
self.writer.gauge("test.gauge", 3.14, actor=ACTOR)
|
|
32
|
+
|
|
33
|
+
def test_log_llm_does_not_raise(self) -> None:
|
|
34
|
+
self.writer.log_llm(model="gpt-4o", tokens=100, actor=ACTOR)
|
|
35
|
+
|
|
36
|
+
def test_doc_used_does_not_raise(self) -> None:
|
|
37
|
+
self.writer.doc_used(doc_id="d1", actor=ACTOR)
|
|
38
|
+
|
|
39
|
+
def test_api_call_does_not_raise(self) -> None:
|
|
40
|
+
self.writer.api_call(endpoint="/test", actor=ACTOR)
|
|
41
|
+
|
|
42
|
+
def test_api_error_does_not_raise(self) -> None:
|
|
43
|
+
self.writer.api_error(endpoint="/test", status=500, actor=ACTOR)
|
|
44
|
+
|
|
45
|
+
def test_record_error_does_not_raise(self) -> None:
|
|
46
|
+
self.writer.record_error(error="oops", actor=ACTOR)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestNoOpKPIWriterTimerContract:
|
|
50
|
+
def setup_method(self) -> None:
|
|
51
|
+
self.writer = NoOpKPIWriter()
|
|
52
|
+
|
|
53
|
+
def test_timer_context_manager_enters_and_exits(self) -> None:
|
|
54
|
+
with self.writer.timer("test.timer", actor=ACTOR):
|
|
55
|
+
pass # must not raise
|
|
56
|
+
|
|
57
|
+
def test_timer_yields_mutable_dims_dict(self) -> None:
|
|
58
|
+
with self.writer.timer("test.timer", actor=ACTOR) as d:
|
|
59
|
+
assert isinstance(d, dict)
|
|
60
|
+
d["agent_id"] = "test-agent"
|
|
61
|
+
# mutation must not raise
|
|
62
|
+
|
|
63
|
+
def test_timer_with_initial_dims_yields_copy(self) -> None:
|
|
64
|
+
initial = {"phase": "routing"}
|
|
65
|
+
with self.writer.timer("test.timer", dims=initial, actor=ACTOR) as d:
|
|
66
|
+
assert d["phase"] == "routing"
|
|
67
|
+
d["extra"] = "added"
|
|
68
|
+
assert "extra" not in initial # original not mutated
|
|
69
|
+
|
|
70
|
+
def test_timer_without_dims_yields_empty_dict(self) -> None:
|
|
71
|
+
with self.writer.timer("test.timer", actor=ACTOR) as d:
|
|
72
|
+
assert d == {}
|
|
73
|
+
|
|
74
|
+
def test_timer_does_not_swallow_exceptions(self) -> None:
|
|
75
|
+
import pytest
|
|
76
|
+
|
|
77
|
+
with pytest.raises(ValueError, match="deliberate"):
|
|
78
|
+
with self.writer.timer("test.timer", actor=ACTOR):
|
|
79
|
+
raise ValueError("deliberate")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestNoOpKPIWriterTimedDecorator:
|
|
83
|
+
def setup_method(self) -> None:
|
|
84
|
+
self.writer = NoOpKPIWriter()
|
|
85
|
+
|
|
86
|
+
def test_timed_calls_wrapped_function(self) -> None:
|
|
87
|
+
called: list[bool] = []
|
|
88
|
+
|
|
89
|
+
@self.writer.timed("test.timed", actor=ACTOR)
|
|
90
|
+
def my_fn() -> str:
|
|
91
|
+
called.append(True)
|
|
92
|
+
return "result"
|
|
93
|
+
|
|
94
|
+
result = my_fn()
|
|
95
|
+
assert result == "result"
|
|
96
|
+
assert called == [True]
|
|
97
|
+
|
|
98
|
+
def test_timed_propagates_return_value(self) -> None:
|
|
99
|
+
@self.writer.timed("test.timed", actor=ACTOR)
|
|
100
|
+
def add(a: int, b: int) -> int:
|
|
101
|
+
return a + b
|
|
102
|
+
|
|
103
|
+
assert add(3, 4) == 7
|
|
104
|
+
|
|
105
|
+
def test_timed_propagates_exceptions(self) -> None:
|
|
106
|
+
import pytest
|
|
107
|
+
|
|
108
|
+
@self.writer.timed("test.timed", actor=ACTOR)
|
|
109
|
+
def explode() -> None:
|
|
110
|
+
raise RuntimeError("boom")
|
|
111
|
+
|
|
112
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
113
|
+
explode()
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline unit tests for fred_core.logs.memory_log_store.
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
- _parse_since: relative (now-Xs/m/h) and absolute (epoch float string)
|
|
6
|
+
- RamLogStore: append, bulk_index, capacity eviction, query filtering
|
|
7
|
+
(time window, level, logger, service, text_like, order, limit)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from fred_core.logs.log_structures import LogEventDTO, LogFilter, LogQuery
|
|
17
|
+
from fred_core.logs.memory_log_store import RamLogStore, _parse_since
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Helpers
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _event(
|
|
25
|
+
msg: str,
|
|
26
|
+
*,
|
|
27
|
+
ts: float | None = None,
|
|
28
|
+
level: str = "INFO",
|
|
29
|
+
logger: str = "app",
|
|
30
|
+
service: str | None = None,
|
|
31
|
+
) -> LogEventDTO:
|
|
32
|
+
return LogEventDTO(
|
|
33
|
+
ts=ts if ts is not None else time.time(),
|
|
34
|
+
level=level,
|
|
35
|
+
logger=logger,
|
|
36
|
+
file="test.py",
|
|
37
|
+
line=1,
|
|
38
|
+
msg=msg,
|
|
39
|
+
service=service,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _query(
|
|
44
|
+
*,
|
|
45
|
+
since: str = "now-1h",
|
|
46
|
+
until: str | None = None,
|
|
47
|
+
level_at_least: str | None = None,
|
|
48
|
+
logger_like: str | None = None,
|
|
49
|
+
service: str | None = None,
|
|
50
|
+
text_like: str | None = None,
|
|
51
|
+
limit: int = 500,
|
|
52
|
+
order: str = "asc",
|
|
53
|
+
) -> LogQuery:
|
|
54
|
+
return LogQuery(
|
|
55
|
+
since=since,
|
|
56
|
+
until=until,
|
|
57
|
+
filters=LogFilter(
|
|
58
|
+
level_at_least=level_at_least,
|
|
59
|
+
logger_like=logger_like,
|
|
60
|
+
service=service,
|
|
61
|
+
text_like=text_like,
|
|
62
|
+
),
|
|
63
|
+
limit=limit,
|
|
64
|
+
order=order,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# _parse_since
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestParseSince:
|
|
74
|
+
def test_now_minus_seconds(self) -> None:
|
|
75
|
+
now = 1000.0
|
|
76
|
+
result = _parse_since("now-30s", now)
|
|
77
|
+
assert result == pytest.approx(970.0)
|
|
78
|
+
|
|
79
|
+
def test_now_minus_minutes(self) -> None:
|
|
80
|
+
now = 1000.0
|
|
81
|
+
result = _parse_since("now-2m", now)
|
|
82
|
+
assert result == pytest.approx(880.0)
|
|
83
|
+
|
|
84
|
+
def test_now_minus_hours(self) -> None:
|
|
85
|
+
now = 7200.0
|
|
86
|
+
result = _parse_since("now-1h", now)
|
|
87
|
+
assert result == pytest.approx(3600.0)
|
|
88
|
+
|
|
89
|
+
def test_epoch_float_string(self) -> None:
|
|
90
|
+
result = _parse_since("1234567890.5", 0.0)
|
|
91
|
+
assert result == pytest.approx(1234567890.5)
|
|
92
|
+
|
|
93
|
+
def test_unsupported_unit_raises(self) -> None:
|
|
94
|
+
with pytest.raises(ValueError, match="Unsupported"):
|
|
95
|
+
_parse_since("now-5d", 1000.0)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# RamLogStore — lifecycle
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestRamLogStoreLifecycle:
|
|
104
|
+
def test_ensure_ready_does_not_raise(self) -> None:
|
|
105
|
+
store = RamLogStore()
|
|
106
|
+
store.ensure_ready()
|
|
107
|
+
|
|
108
|
+
def test_empty_store_returns_no_events(self) -> None:
|
|
109
|
+
store = RamLogStore()
|
|
110
|
+
result = store.query(_query())
|
|
111
|
+
assert result.events == []
|
|
112
|
+
|
|
113
|
+
def test_capacity_evicts_oldest(self) -> None:
|
|
114
|
+
store = RamLogStore(capacity=3)
|
|
115
|
+
base = (
|
|
116
|
+
time.time() - 300
|
|
117
|
+
) # anchor in the past so all events are within the query window
|
|
118
|
+
for i in range(5):
|
|
119
|
+
store.index_event(_event(f"msg-{i}", ts=base + i))
|
|
120
|
+
result = store.query(_query(since="now-1h", limit=10))
|
|
121
|
+
msgs = [e.msg for e in result.events]
|
|
122
|
+
assert "msg-0" not in msgs
|
|
123
|
+
assert "msg-1" not in msgs
|
|
124
|
+
assert "msg-4" in msgs
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# RamLogStore — writes
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestRamLogStoreWrites:
|
|
133
|
+
def test_single_event_indexed(self) -> None:
|
|
134
|
+
store = RamLogStore()
|
|
135
|
+
store.index_event(_event("hello"))
|
|
136
|
+
result = store.query(_query())
|
|
137
|
+
assert len(result.events) == 1
|
|
138
|
+
assert result.events[0].msg == "hello"
|
|
139
|
+
|
|
140
|
+
def test_bulk_index(self) -> None:
|
|
141
|
+
store = RamLogStore()
|
|
142
|
+
events = [_event(f"msg-{i}") for i in range(5)]
|
|
143
|
+
store.bulk_index(events)
|
|
144
|
+
result = store.query(_query())
|
|
145
|
+
assert len(result.events) == 5
|
|
146
|
+
|
|
147
|
+
def test_bulk_index_empty_list_does_nothing(self) -> None:
|
|
148
|
+
store = RamLogStore()
|
|
149
|
+
store.bulk_index([])
|
|
150
|
+
result = store.query(_query())
|
|
151
|
+
assert result.events == []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# RamLogStore — query: time window
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestRamLogStoreQueryTimeWindow:
|
|
160
|
+
def test_event_outside_window_excluded(self) -> None:
|
|
161
|
+
store = RamLogStore()
|
|
162
|
+
old_ts = time.time() - 7200 # 2 hours ago
|
|
163
|
+
store.index_event(_event("old", ts=old_ts))
|
|
164
|
+
result = store.query(_query(since="now-1h"))
|
|
165
|
+
assert result.events == []
|
|
166
|
+
|
|
167
|
+
def test_event_inside_window_included(self) -> None:
|
|
168
|
+
store = RamLogStore()
|
|
169
|
+
store.index_event(_event("recent", ts=time.time() - 60))
|
|
170
|
+
result = store.query(_query(since="now-1h"))
|
|
171
|
+
assert len(result.events) == 1
|
|
172
|
+
|
|
173
|
+
def test_until_excludes_future_events(self) -> None:
|
|
174
|
+
store = RamLogStore()
|
|
175
|
+
past = time.time() - 300
|
|
176
|
+
future = time.time() + 300
|
|
177
|
+
store.index_event(_event("past", ts=past))
|
|
178
|
+
store.index_event(_event("future", ts=future))
|
|
179
|
+
result = store.query(_query(since="now-1h", until="now-0s"))
|
|
180
|
+
msgs = [e.msg for e in result.events]
|
|
181
|
+
assert "past" in msgs
|
|
182
|
+
assert "future" not in msgs
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# RamLogStore — query: filters
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TestRamLogStoreQueryFilters:
|
|
191
|
+
def _populated_store(self) -> RamLogStore:
|
|
192
|
+
store = RamLogStore()
|
|
193
|
+
now = time.time() - 10
|
|
194
|
+
store.bulk_index(
|
|
195
|
+
[
|
|
196
|
+
_event(
|
|
197
|
+
"debug msg",
|
|
198
|
+
ts=now,
|
|
199
|
+
level="DEBUG",
|
|
200
|
+
logger="app.debug",
|
|
201
|
+
service="svc-a",
|
|
202
|
+
),
|
|
203
|
+
_event(
|
|
204
|
+
"info msg", ts=now, level="INFO", logger="app.info", service="svc-a"
|
|
205
|
+
),
|
|
206
|
+
_event(
|
|
207
|
+
"warn msg",
|
|
208
|
+
ts=now,
|
|
209
|
+
level="WARNING",
|
|
210
|
+
logger="app.warn",
|
|
211
|
+
service="svc-b",
|
|
212
|
+
),
|
|
213
|
+
_event(
|
|
214
|
+
"error msg",
|
|
215
|
+
ts=now,
|
|
216
|
+
level="ERROR",
|
|
217
|
+
logger="app.error",
|
|
218
|
+
service="svc-b",
|
|
219
|
+
),
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
return store
|
|
223
|
+
|
|
224
|
+
def test_level_at_least_warning_excludes_debug_info(self) -> None:
|
|
225
|
+
store = self._populated_store()
|
|
226
|
+
result = store.query(_query(level_at_least="WARNING"))
|
|
227
|
+
levels = {e.level for e in result.events}
|
|
228
|
+
assert "DEBUG" not in levels
|
|
229
|
+
assert "INFO" not in levels
|
|
230
|
+
assert "WARNING" in levels
|
|
231
|
+
assert "ERROR" in levels
|
|
232
|
+
|
|
233
|
+
def test_level_at_least_error_keeps_only_error(self) -> None:
|
|
234
|
+
store = self._populated_store()
|
|
235
|
+
result = store.query(_query(level_at_least="ERROR"))
|
|
236
|
+
assert all(e.level == "ERROR" for e in result.events)
|
|
237
|
+
|
|
238
|
+
def test_logger_like_substring_match(self) -> None:
|
|
239
|
+
store = self._populated_store()
|
|
240
|
+
result = store.query(_query(logger_like="warn"))
|
|
241
|
+
assert all("warn" in e.logger for e in result.events)
|
|
242
|
+
|
|
243
|
+
def test_service_exact_match(self) -> None:
|
|
244
|
+
store = self._populated_store()
|
|
245
|
+
result = store.query(_query(service="svc-a"))
|
|
246
|
+
assert all(e.service == "svc-a" for e in result.events)
|
|
247
|
+
|
|
248
|
+
def test_text_like_case_insensitive(self) -> None:
|
|
249
|
+
store = self._populated_store()
|
|
250
|
+
result = store.query(_query(text_like="WARN"))
|
|
251
|
+
assert len(result.events) == 1
|
|
252
|
+
assert result.events[0].msg == "warn msg"
|
|
253
|
+
|
|
254
|
+
def test_no_filters_returns_all(self) -> None:
|
|
255
|
+
store = self._populated_store()
|
|
256
|
+
result = store.query(_query())
|
|
257
|
+
assert len(result.events) == 4
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# RamLogStore — query: ordering and limit
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TestRamLogStoreQueryOrderAndLimit:
|
|
266
|
+
def _store_with_timestamps(self) -> RamLogStore:
|
|
267
|
+
store = RamLogStore()
|
|
268
|
+
base = time.time() - 100
|
|
269
|
+
store.bulk_index([_event(f"msg-{i}", ts=base + i) for i in range(5)])
|
|
270
|
+
return store
|
|
271
|
+
|
|
272
|
+
def test_asc_order(self) -> None:
|
|
273
|
+
store = self._store_with_timestamps()
|
|
274
|
+
result = store.query(_query(order="asc"))
|
|
275
|
+
ts_list = [e.ts for e in result.events]
|
|
276
|
+
assert ts_list == sorted(ts_list)
|
|
277
|
+
|
|
278
|
+
def test_desc_order(self) -> None:
|
|
279
|
+
store = self._store_with_timestamps()
|
|
280
|
+
result = store.query(_query(order="desc"))
|
|
281
|
+
ts_list = [e.ts for e in result.events]
|
|
282
|
+
assert ts_list == sorted(ts_list, reverse=True)
|
|
283
|
+
|
|
284
|
+
def test_limit_truncates_results(self) -> None:
|
|
285
|
+
store = self._store_with_timestamps()
|
|
286
|
+
result = store.query(_query(limit=3))
|
|
287
|
+
assert len(result.events) == 3
|
|
@@ -12,7 +12,16 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from fred_core.security.authorization import (
|
|
18
|
+
Action,
|
|
19
|
+
Resource,
|
|
20
|
+
authorize_or_raise,
|
|
21
|
+
is_authorized,
|
|
22
|
+
require_admin,
|
|
23
|
+
)
|
|
24
|
+
from fred_core.security.models import AuthorizationError
|
|
16
25
|
from fred_core.security.rbac import RBACProvider
|
|
17
26
|
from fred_core.security.structure import KeycloakUser
|
|
18
27
|
|
|
@@ -131,3 +140,37 @@ class TestRBACProvider:
|
|
|
131
140
|
assert self.rbac.is_authorized(multi_role_user, Action.CREATE, Resource.TAGS)
|
|
132
141
|
assert self.rbac.is_authorized(multi_role_user, Action.DELETE, Resource.TAGS)
|
|
133
142
|
assert self.rbac.is_authorized(multi_role_user, Action.READ, Resource.TAGS)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _admin() -> KeycloakUser:
|
|
146
|
+
return KeycloakUser(uid="a1", username="admin", email="a@t.com", roles=["admin"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _viewer() -> KeycloakUser:
|
|
150
|
+
return KeycloakUser(uid="v1", username="viewer", email="v@t.com", roles=["viewer"])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestIsAuthorized:
|
|
154
|
+
def test_admin_is_authorized(self) -> None:
|
|
155
|
+
assert is_authorized(_admin(), Action.READ, Resource.TAGS) is True
|
|
156
|
+
|
|
157
|
+
def test_viewer_denied_create(self) -> None:
|
|
158
|
+
assert is_authorized(_viewer(), Action.CREATE, Resource.TAGS) is False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestAuthorizeOrRaise:
|
|
162
|
+
def test_authorized_does_not_raise(self) -> None:
|
|
163
|
+
authorize_or_raise(_admin(), Action.READ, Resource.TAGS)
|
|
164
|
+
|
|
165
|
+
def test_unauthorized_raises_authorization_error(self) -> None:
|
|
166
|
+
with pytest.raises(AuthorizationError):
|
|
167
|
+
authorize_or_raise(_viewer(), Action.CREATE, Resource.TAGS)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestRequireAdmin:
|
|
171
|
+
def test_admin_passes(self) -> None:
|
|
172
|
+
require_admin(_admin())
|
|
173
|
+
|
|
174
|
+
def test_non_admin_raises(self) -> None:
|
|
175
|
+
with pytest.raises(AuthorizationError):
|
|
176
|
+
require_admin(_viewer())
|