starfish-audit 3.0.0a5__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.
- starfish_audit-3.0.0a5/PKG-INFO +11 -0
- starfish_audit-3.0.0a5/README.md +53 -0
- starfish_audit-3.0.0a5/pyproject.toml +28 -0
- starfish_audit-3.0.0a5/setup.cfg +4 -0
- starfish_audit-3.0.0a5/starfish_audit/__init__.py +22 -0
- starfish_audit-3.0.0a5/starfish_audit/audit.py +44 -0
- starfish_audit-3.0.0a5/starfish_audit.egg-info/PKG-INFO +11 -0
- starfish_audit-3.0.0a5/starfish_audit.egg-info/SOURCES.txt +11 -0
- starfish_audit-3.0.0a5/starfish_audit.egg-info/dependency_links.txt +1 -0
- starfish_audit-3.0.0a5/starfish_audit.egg-info/requires.txt +7 -0
- starfish_audit-3.0.0a5/starfish_audit.egg-info/top_level.txt +1 -0
- starfish_audit-3.0.0a5/tests/test_audit.py +55 -0
- starfish_audit-3.0.0a5/tests/test_audit_router.py +227 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: starfish-audit
|
|
3
|
+
Version: 3.0.0a5
|
|
4
|
+
Summary: Starfish audit logging extension (console / callback / no-op audit loggers for server pull/push events)
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: starfish-protocol
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
10
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
11
|
+
Requires-Dist: starfish-server; extra == "dev"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# starfish-audit
|
|
2
|
+
|
|
3
|
+
Starfish audit logging extension for Python — ready-made audit loggers for
|
|
4
|
+
recording the server's pull/push access events. The `AuditEntry` / `AuditLogger`
|
|
5
|
+
contract lives in `starfish-protocol`; this package ships the concrete loggers.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install starfish-server starfish-audit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Pass a logger to the sync router via `audit_logger`:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from starfish_server.router.route_builder import create_sync_router, SyncRouterOptions
|
|
19
|
+
from starfish_audit import ConsoleAuditLogger
|
|
20
|
+
|
|
21
|
+
router = create_sync_router(
|
|
22
|
+
SyncRouterOptions(
|
|
23
|
+
store=store,
|
|
24
|
+
config=config,
|
|
25
|
+
role_resolver=role_resolver,
|
|
26
|
+
audit_logger=ConsoleAuditLogger(),
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Write your own sink with `CallbackAuditLogger` (sync or async callback):
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from starfish_audit import CallbackAuditLogger, AuditEntry
|
|
35
|
+
|
|
36
|
+
async def _record(entry: AuditEntry) -> None:
|
|
37
|
+
await db.audit.insert(entry)
|
|
38
|
+
|
|
39
|
+
audit_logger = CallbackAuditLogger(_record)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`NoopAuditLogger` discards entries.
|
|
43
|
+
|
|
44
|
+
The server **awaits** `record()` for each request, so the entry is durable before
|
|
45
|
+
the response is returned. Keep the sink fast and resilient: a slow logger adds
|
|
46
|
+
request latency and a raising one surfaces as a request error.
|
|
47
|
+
|
|
48
|
+
> Audit logging is server-side observability — this package depends only on
|
|
49
|
+
> `starfish-protocol` and registers no cap-cert validator. Note: the Python
|
|
50
|
+
> server currently emits audit entries on **push** operations (the TypeScript
|
|
51
|
+
> server emits on both pull and push).
|
|
52
|
+
|
|
53
|
+
See `docs/python/audit/` (and the TypeScript counterpart in `docs/ts/audit/`) for the full guide.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "starfish-audit"
|
|
7
|
+
version = "3.0.0a5"
|
|
8
|
+
description = "Starfish audit logging extension (console / callback / no-op audit loggers for server pull/push events)"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"starfish-protocol",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = [
|
|
16
|
+
"pytest>=7.0",
|
|
17
|
+
"pytest-asyncio>=0.21",
|
|
18
|
+
"httpx>=0.27",
|
|
19
|
+
"starfish-server",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.uv.sources]
|
|
23
|
+
starfish-protocol = { path = "../protocol", editable = true }
|
|
24
|
+
starfish-server = { path = "../server", editable = true }
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
asyncio_mode = "auto"
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""``starfish-audit`` — audit logging extension.
|
|
2
|
+
|
|
3
|
+
Ready-made audit loggers for recording the server's pull/push access events.
|
|
4
|
+
The ``AuditEntry`` / ``AuditLogger`` contract lives in ``starfish_protocol``;
|
|
5
|
+
this package re-exports them alongside the concrete loggers for convenience.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from starfish_protocol import AuditEntry, AuditLogger
|
|
9
|
+
|
|
10
|
+
from starfish_audit.audit import (
|
|
11
|
+
CallbackAuditLogger,
|
|
12
|
+
ConsoleAuditLogger,
|
|
13
|
+
NoopAuditLogger,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AuditEntry",
|
|
18
|
+
"AuditLogger",
|
|
19
|
+
"ConsoleAuditLogger",
|
|
20
|
+
"CallbackAuditLogger",
|
|
21
|
+
"NoopAuditLogger",
|
|
22
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Concrete audit loggers for the Starfish server.
|
|
2
|
+
|
|
3
|
+
The ``AuditEntry`` dataclass and ``AuditLogger`` base class live in
|
|
4
|
+
``starfish_protocol``; this module supplies the ready-made implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
from starfish_protocol import AuditEntry, AuditLogger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConsoleAuditLogger(AuditLogger):
|
|
14
|
+
"""Audit logger that writes to console."""
|
|
15
|
+
|
|
16
|
+
async def record(self, entry: AuditEntry) -> None:
|
|
17
|
+
status = "OK" if entry.success else "FAIL"
|
|
18
|
+
identity = entry.identity or "anonymous"
|
|
19
|
+
print(
|
|
20
|
+
f"[Starfish:AUDIT] {entry.action.upper()} {entry.collection} "
|
|
21
|
+
f"by {identity} → {status} ({entry.status_code})"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CallbackAuditLogger(AuditLogger):
|
|
26
|
+
"""Audit logger that delegates to a sync or async callback."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, callback: Callable[[AuditEntry], None | Awaitable[None]]) -> None:
|
|
29
|
+
self._callback = callback
|
|
30
|
+
|
|
31
|
+
async def record(self, entry: AuditEntry) -> None:
|
|
32
|
+
result = self._callback(entry)
|
|
33
|
+
if inspect.isawaitable(result):
|
|
34
|
+
await result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NoopAuditLogger(AuditLogger):
|
|
38
|
+
"""No-op audit logger (discards entries)."""
|
|
39
|
+
|
|
40
|
+
async def record(self, entry: AuditEntry) -> None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = ["ConsoleAuditLogger", "CallbackAuditLogger", "NoopAuditLogger"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: starfish-audit
|
|
3
|
+
Version: 3.0.0a5
|
|
4
|
+
Summary: Starfish audit logging extension (console / callback / no-op audit loggers for server pull/push events)
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: starfish-protocol
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
10
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
11
|
+
Requires-Dist: starfish-server; extra == "dev"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
starfish_audit/__init__.py
|
|
4
|
+
starfish_audit/audit.py
|
|
5
|
+
starfish_audit.egg-info/PKG-INFO
|
|
6
|
+
starfish_audit.egg-info/SOURCES.txt
|
|
7
|
+
starfish_audit.egg-info/dependency_links.txt
|
|
8
|
+
starfish_audit.egg-info/requires.txt
|
|
9
|
+
starfish_audit.egg-info/top_level.txt
|
|
10
|
+
tests/test_audit.py
|
|
11
|
+
tests/test_audit_router.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
starfish_audit
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Tests for audit logging."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from starfish_audit import AuditEntry, ConsoleAuditLogger, CallbackAuditLogger, NoopAuditLogger
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _make_entry() -> AuditEntry:
|
|
8
|
+
return AuditEntry(
|
|
9
|
+
timestamp=1234,
|
|
10
|
+
action="pull",
|
|
11
|
+
collection="settings",
|
|
12
|
+
identity="user-1",
|
|
13
|
+
document_key="users/user-1/settings",
|
|
14
|
+
success=True,
|
|
15
|
+
status_code=200,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_console_audit_logger(capsys):
|
|
21
|
+
logger = ConsoleAuditLogger()
|
|
22
|
+
await logger.record(_make_entry())
|
|
23
|
+
captured = capsys.readouterr()
|
|
24
|
+
assert "PULL" in captured.out
|
|
25
|
+
assert "settings" in captured.out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.asyncio
|
|
29
|
+
async def test_callback_audit_logger_sync():
|
|
30
|
+
entries = []
|
|
31
|
+
logger = CallbackAuditLogger(lambda e: entries.append(e))
|
|
32
|
+
entry = _make_entry()
|
|
33
|
+
await logger.record(entry)
|
|
34
|
+
assert len(entries) == 1
|
|
35
|
+
assert entries[0] == entry
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_callback_audit_logger_async():
|
|
40
|
+
entries = []
|
|
41
|
+
async def async_cb(e: AuditEntry) -> None:
|
|
42
|
+
entries.append(e)
|
|
43
|
+
logger = CallbackAuditLogger(async_cb)
|
|
44
|
+
entry = _make_entry()
|
|
45
|
+
await logger.record(entry)
|
|
46
|
+
assert len(entries) == 1
|
|
47
|
+
assert entries[0] == entry
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.asyncio
|
|
51
|
+
async def test_noop_audit_logger(capsys):
|
|
52
|
+
logger = NoopAuditLogger()
|
|
53
|
+
await logger.record(_make_entry())
|
|
54
|
+
captured = capsys.readouterr()
|
|
55
|
+
assert captured.out == ""
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""AuditLogger must be called on push operations in the Python router.
|
|
2
|
+
This test suite verifies that
|
|
3
|
+
SyncRouterOptions.audit_logger is wired through the push handler.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from fastapi import FastAPI, Request
|
|
10
|
+
from httpx import AsyncClient, ASGITransport
|
|
11
|
+
|
|
12
|
+
from starfish_server.config.schema import SyncConfig, CollectionConfig
|
|
13
|
+
from starfish_server.router.route_builder import create_sync_router, SyncRouterOptions, AuthResult
|
|
14
|
+
from starfish_audit import AuditLogger, AuditEntry
|
|
15
|
+
from tests.helpers import MemoryObjectStore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_app_with_audit(audit_logger: AuditLogger) -> tuple[FastAPI, MemoryObjectStore]:
|
|
19
|
+
store = MemoryObjectStore()
|
|
20
|
+
config = SyncConfig(
|
|
21
|
+
version=1,
|
|
22
|
+
collections=[
|
|
23
|
+
CollectionConfig(
|
|
24
|
+
name="settings",
|
|
25
|
+
storagePath="users/{identity}/settings",
|
|
26
|
+
readRoles=["self"],
|
|
27
|
+
writeRoles=["self"],
|
|
28
|
+
encryption="none",
|
|
29
|
+
maxBodyBytes=65536,
|
|
30
|
+
),
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def role_resolver(request: Request) -> AuthResult:
|
|
35
|
+
return AuthResult(identity="user-1", roles=["self"])
|
|
36
|
+
|
|
37
|
+
router = create_sync_router(
|
|
38
|
+
SyncRouterOptions(
|
|
39
|
+
store=store,
|
|
40
|
+
config=config,
|
|
41
|
+
role_resolver=role_resolver,
|
|
42
|
+
audit_logger=audit_logger,
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
app = FastAPI()
|
|
46
|
+
app.include_router(router)
|
|
47
|
+
return app, store
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ─── successful push emits audit entry ──────────────────────────────────
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_successful_push_records_audit_entry():
|
|
54
|
+
"""A 200 push must call audit_logger.record with success=True."""
|
|
55
|
+
recorded: list[AuditEntry] = []
|
|
56
|
+
|
|
57
|
+
class CapturingLogger(AuditLogger):
|
|
58
|
+
async def record(self, entry: AuditEntry) -> None:
|
|
59
|
+
recorded.append(entry)
|
|
60
|
+
|
|
61
|
+
app, _ = _make_app_with_audit(CapturingLogger())
|
|
62
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
63
|
+
resp = await client.post(
|
|
64
|
+
"/push/users/user-1/settings",
|
|
65
|
+
json={"data": {"x": 1}, "baseHash": None},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert resp.status_code == 200
|
|
69
|
+
assert len(recorded) == 1
|
|
70
|
+
entry = recorded[0]
|
|
71
|
+
assert entry.action == "push"
|
|
72
|
+
assert entry.success is True
|
|
73
|
+
assert entry.status_code == 200
|
|
74
|
+
assert entry.collection == "settings"
|
|
75
|
+
assert entry.identity == "user-1"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ─── conflict (409) push emits audit entry with failure ─────────────────
|
|
79
|
+
|
|
80
|
+
@pytest.mark.asyncio
|
|
81
|
+
async def test_conflict_push_records_audit_entry():
|
|
82
|
+
""" A 409 conflict push must call audit_logger.record with success=False."""
|
|
83
|
+
recorded: list[AuditEntry] = []
|
|
84
|
+
|
|
85
|
+
class CapturingLogger(AuditLogger):
|
|
86
|
+
async def record(self, entry: AuditEntry) -> None:
|
|
87
|
+
recorded.append(entry)
|
|
88
|
+
|
|
89
|
+
app, _ = _make_app_with_audit(CapturingLogger())
|
|
90
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
91
|
+
# First push succeeds
|
|
92
|
+
await client.post(
|
|
93
|
+
"/push/users/user-1/settings",
|
|
94
|
+
json={"data": {"x": 1}, "baseHash": None},
|
|
95
|
+
)
|
|
96
|
+
# Second push with wrong baseHash → 409
|
|
97
|
+
conflict_resp = await client.post(
|
|
98
|
+
"/push/users/user-1/settings",
|
|
99
|
+
json={"data": {"x": 2}, "baseHash": "wrong-hash"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert conflict_resp.status_code == 409
|
|
103
|
+
assert len(recorded) == 2 # both pushes recorded
|
|
104
|
+
conflict_entry = recorded[1]
|
|
105
|
+
assert conflict_entry.action == "push"
|
|
106
|
+
assert conflict_entry.success is False
|
|
107
|
+
assert conflict_entry.status_code == 409
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ─── no audit_logger → no error ────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
async def test_push_without_audit_logger_does_not_crash():
|
|
114
|
+
""" When audit_logger is None (default), push must still work normally."""
|
|
115
|
+
store = MemoryObjectStore()
|
|
116
|
+
config = SyncConfig(
|
|
117
|
+
version=1,
|
|
118
|
+
collections=[
|
|
119
|
+
CollectionConfig(
|
|
120
|
+
name="settings",
|
|
121
|
+
storagePath="users/{identity}/settings",
|
|
122
|
+
readRoles=["self"],
|
|
123
|
+
writeRoles=["self"],
|
|
124
|
+
encryption="none",
|
|
125
|
+
maxBodyBytes=65536,
|
|
126
|
+
),
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def role_resolver(request: Request) -> AuthResult:
|
|
131
|
+
return AuthResult(identity="user-1", roles=["self"])
|
|
132
|
+
|
|
133
|
+
# No audit_logger passed → must default to None
|
|
134
|
+
router = create_sync_router(
|
|
135
|
+
SyncRouterOptions(store=store, config=config, role_resolver=role_resolver),
|
|
136
|
+
)
|
|
137
|
+
app = FastAPI()
|
|
138
|
+
app.include_router(router)
|
|
139
|
+
|
|
140
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
141
|
+
resp = await client.post(
|
|
142
|
+
"/push/users/user-1/settings",
|
|
143
|
+
json={"data": {"y": 2}, "baseHash": None},
|
|
144
|
+
)
|
|
145
|
+
assert resp.status_code == 200
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ─── auth denial (403) emits an audit entry with failure ────────────────
|
|
149
|
+
|
|
150
|
+
@pytest.mark.asyncio
|
|
151
|
+
async def test_denied_push_records_audit_entry():
|
|
152
|
+
"""A 403 auth denial must call audit_logger.record with success=False.
|
|
153
|
+
|
|
154
|
+
Without this, only requests that pass the auth gate are ever audited — so
|
|
155
|
+
denied (401/403) attempts leave no trace in the trail.
|
|
156
|
+
"""
|
|
157
|
+
recorded: list[AuditEntry] = []
|
|
158
|
+
|
|
159
|
+
class CapturingLogger(AuditLogger):
|
|
160
|
+
async def record(self, entry: AuditEntry) -> None:
|
|
161
|
+
recorded.append(entry)
|
|
162
|
+
|
|
163
|
+
store = MemoryObjectStore()
|
|
164
|
+
config = SyncConfig(
|
|
165
|
+
version=1,
|
|
166
|
+
collections=[
|
|
167
|
+
CollectionConfig(
|
|
168
|
+
name="settings",
|
|
169
|
+
storagePath="users/{identity}/settings",
|
|
170
|
+
readRoles=["admin"],
|
|
171
|
+
writeRoles=["admin"], # a role the caller never holds
|
|
172
|
+
encryption="none",
|
|
173
|
+
maxBodyBytes=65536,
|
|
174
|
+
),
|
|
175
|
+
],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def role_resolver(request: Request) -> AuthResult:
|
|
179
|
+
return AuthResult(identity="user-1", roles=[]) # no roles → write denied
|
|
180
|
+
|
|
181
|
+
router = create_sync_router(
|
|
182
|
+
SyncRouterOptions(
|
|
183
|
+
store=store, config=config, role_resolver=role_resolver, audit_logger=CapturingLogger()
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
app = FastAPI()
|
|
187
|
+
app.include_router(router)
|
|
188
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
189
|
+
resp = await client.post(
|
|
190
|
+
"/push/users/user-1/settings",
|
|
191
|
+
json={"data": {"x": 1}, "baseHash": None},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
assert resp.status_code == 403
|
|
195
|
+
assert len(recorded) == 1
|
|
196
|
+
assert recorded[0].action == "push"
|
|
197
|
+
assert recorded[0].success is False
|
|
198
|
+
assert recorded[0].status_code == 403
|
|
199
|
+
assert recorded[0].collection == "settings"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@pytest.mark.asyncio
|
|
203
|
+
async def test_awaits_async_audit_logger_before_returning_push_response():
|
|
204
|
+
# Cross-language divergence. Python does `await audit_logger.record(...)`, so an
|
|
205
|
+
# async logger's write completes before the response is returned (this test).
|
|
206
|
+
# TS calls `opts.auditLogger.record(...)` WITHOUT await, so an async logger is
|
|
207
|
+
# fire-and-forget — the entry may not be written before the response, and a
|
|
208
|
+
# rejected logger becomes an unhandled rejection. This is the reference for the
|
|
209
|
+
# convergent durable behaviour; the TS side is pinned as it.fails in
|
|
210
|
+
# router-emission.test.ts.
|
|
211
|
+
audit_completed = False
|
|
212
|
+
|
|
213
|
+
class SlowLogger(AuditLogger):
|
|
214
|
+
async def record(self, entry: AuditEntry) -> None:
|
|
215
|
+
nonlocal audit_completed
|
|
216
|
+
await asyncio.sleep(0)
|
|
217
|
+
audit_completed = True
|
|
218
|
+
|
|
219
|
+
app, _ = _make_app_with_audit(SlowLogger())
|
|
220
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
221
|
+
resp = await client.post(
|
|
222
|
+
"/push/users/user-1/settings",
|
|
223
|
+
json={"data": {"x": 1}, "baseHash": None},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
assert resp.status_code == 200
|
|
227
|
+
assert audit_completed is True # Python awaits the record call before responding
|