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.
Files changed (123) hide show
  1. {fred_core-2.0.1 → fred_core-2.0.2}/PKG-INFO +1 -1
  2. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/oidc.py +8 -4
  3. fred_core-2.0.2/fred_core/tests/common/test_lru_cache.py +127 -0
  4. fred_core-2.0.2/fred_core/tests/kpi/test_noop_kpi_writer.py +113 -0
  5. fred_core-2.0.2/fred_core/tests/logs/test_memory_log_store.py +287 -0
  6. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/security/test_authorization.py +44 -1
  7. fred_core-2.0.2/fred_core/tests/security/test_whitelist_access_control.py +130 -0
  8. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/PKG-INFO +1 -1
  9. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/SOURCES.txt +4 -0
  10. {fred_core-2.0.1 → fred_core-2.0.2}/pyproject.toml +34 -1
  11. {fred_core-2.0.1 → fred_core-2.0.2}/README.md +0 -0
  12. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/__init__.py +0 -0
  13. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/cli/__init__.py +0 -0
  14. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/cli/auth.py +0 -0
  15. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/cli/ui.py +0 -0
  16. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/__init__.py +0 -0
  17. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/config_files.py +0 -0
  18. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/config_loader.py +0 -0
  19. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/env.py +0 -0
  20. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/fastapi_handlers.py +0 -0
  21. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/lru_cache.py +0 -0
  22. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/structures.py +0 -0
  23. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/team_id.py +0 -0
  24. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/common/utils.py +0 -0
  25. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/filesystem/local_filesystem.py +0 -0
  26. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/filesystem/minio_filesystem.py +0 -0
  27. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/filesystem/structures.py +0 -0
  28. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/__init__.py +0 -0
  29. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/base_history_store.py +0 -0
  30. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/history_models.py +0 -0
  31. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/history_schema.py +0 -0
  32. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/history/postgres_history_store.py +0 -0
  33. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/__init__.py +0 -0
  34. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/base_kpi_store.py +0 -0
  35. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/base_kpi_writer.py +0 -0
  36. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_phase_metric.py +0 -0
  37. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_process.py +0 -0
  38. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_reader_structures.py +0 -0
  39. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_writer.py +0 -0
  40. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/kpi_writer_structures.py +0 -0
  41. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/log_kpi_store.py +0 -0
  42. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/noop_kpi_writer.py +0 -0
  43. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/opensearch_kpi_store.py +0 -0
  44. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/kpi/prometheus_kpi_store.py +0 -0
  45. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/__init__.py +0 -0
  46. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/base_log_store.py +0 -0
  47. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/log_setup.py +0 -0
  48. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/log_structures.py +0 -0
  49. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/memory_log_store.py +0 -0
  50. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/null_log_store.py +0 -0
  51. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/logs/opensearch_log_store.py +0 -0
  52. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/model/factory.py +0 -0
  53. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/model/http_clients.py +0 -0
  54. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/model/models.py +0 -0
  55. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/models/__init__.py +0 -0
  56. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/models/base.py +0 -0
  57. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/portable/__init__.py +0 -0
  58. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/portable/observability.py +0 -0
  59. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/py.typed +0 -0
  60. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/scheduler/__init__.py +0 -0
  61. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/scheduler/backend.py +0 -0
  62. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/scheduler/scheduler_structures.py +0 -0
  63. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/scheduler/temporal_client_provider.py +0 -0
  64. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/__init__.py +0 -0
  65. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/authorization.py +0 -0
  66. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/authorization_decorator.py +0 -0
  67. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/backend_to_backend_auth.py +0 -0
  68. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/keycloak/__init__.py +0 -0
  69. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/keycloak/keycloack_admin_client.py +0 -0
  70. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/models.py +0 -0
  71. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/outbound.py +0 -0
  72. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rbac.py +0 -0
  73. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/noop_engine.py +0 -0
  74. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/openfga_engine.py +0 -0
  75. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/openfga_schema.py +0 -0
  76. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/rebac_engine.py +0 -0
  77. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/rebac_factory.py +0 -0
  78. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/schema.fga +0 -0
  79. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/rebac/schema.fga.json +0 -0
  80. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/structure.py +0 -0
  81. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/security/whitelist_access_control/access_control.py +0 -0
  82. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/__init__.py +0 -0
  83. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/session_schema.py +0 -0
  84. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/stores/__init__.py +0 -0
  85. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/stores/base_session_store.py +0 -0
  86. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/stores/postgres_session_store.py +0 -0
  87. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/session/stores/session_models.py +0 -0
  88. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/__init__.py +0 -0
  89. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/alembic_env.py +0 -0
  90. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/async_session.py +0 -0
  91. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/base_sql.py +0 -0
  92. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/sql/mixin.py +0 -0
  93. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/__init__.py +0 -0
  94. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/base_content_store.py +0 -0
  95. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/local_content_store.py +0 -0
  96. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/minio_content_store.py +0 -0
  97. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/opensearch_mapping_validator.py +0 -0
  98. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/store/vector_search.py +0 -0
  99. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/__init__.py +0 -0
  100. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/cli/test_auth.py +0 -0
  101. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/cli/test_ui.py +0 -0
  102. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/common/test_config_loader.py +0 -0
  103. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/common/test_env.py +0 -0
  104. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/common/test_log_setup.py +0 -0
  105. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/integration/__init__.py +0 -0
  106. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/integration/test_rebac.py +0 -0
  107. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/model/test_embedding_factory.py +0 -0
  108. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/model/test_vertex_model_garden_auth.py +0 -0
  109. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/scheduler/test_backend.py +0 -0
  110. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/security/__init__.py +0 -0
  111. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/security/test_rebac_engine_team_helpers.py +0 -0
  112. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/session/test_postgres_json_session_store_sqlite.py +0 -0
  113. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/test_log_kpi_store.py +0 -0
  114. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/tests/test_prometheus_kpi_store.py +0 -0
  115. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/__init__.py +0 -0
  116. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/store/__init__.py +0 -0
  117. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/store/base_user_store.py +0 -0
  118. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/store/postgres_user_store.py +0 -0
  119. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core/users/user_models.py +0 -0
  120. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/dependency_links.txt +0 -0
  121. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/requires.txt +0 -0
  122. {fred_core-2.0.1 → fred_core-2.0.2}/fred_core.egg-info/top_level.txt +0 -0
  123. {fred_core-2.0.1 → fred_core-2.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-core
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Core shared utilities for Fred backends (config, storage, security, and runtime helpers).
5
5
  Author-email: Thales <noreply@thalesgroup.com>
6
6
  License: Apache-2.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
- logger.debug("[SECURITY] Authentication is DISABLED. Returning a mock user.")
250
+ username = getpass.getuser()
251
+ logger.debug(
252
+ "[SECURITY] Authentication is DISABLED. Returning mock user: %s", username
253
+ )
250
254
  return KeycloakUser(
251
- uid="admin",
252
- username="admin",
255
+ uid=username,
256
+ username=username,
253
257
  roles=["admin"],
254
- email="dev@localhost",
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
- from fred_core.security.authorization import Action, Resource
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())