fix(auth): resolve JWT InvalidSignatureError and improve frontend UX
Some checks failed
CI / lint-and-test (push) Has been cancelled

- Fix auth by using idToken fallback when accessToken audience mismatches
- Add PyJWT verification with audience-aware token selection in frontend
- Source health: track last_attempt_time and error status per source
- Frontend: fix modal outside x-data scope, add circular-safe JSON stringify
- Frontend: support multi-select service filter with All/None toggles
- Frontend: improve filter layout into organized rows
- Frontend: fix text overflow and result pill colors (success/succeeded)
- Intune: normalize application actors (auditActorType=Application)
- Add cache-control middleware for HTML/API responses
- Update tests for multi-service filtering and source health
This commit is contained in:
2026-04-16 11:32:45 +02:00
parent ed310a06de
commit 82bafc06c9
12 changed files with 350 additions and 103 deletions

View File

@@ -6,7 +6,10 @@ FETCH_INTERVAL_MINUTES=60
AUTH_ENABLED=false
AUTH_TENANT_ID=your-tenant-id
AUTH_CLIENT_ID=your-api-client-id
# Optional scope for SPA login (e.g., api://<client-id>/access_as_user)
# API scope the SPA should request at login.
# When set, the frontend acquires an access token for this scope (aud = AUTH_CLIENT_ID).
# When empty, the frontend falls back to the idToken, which is also valid for the backend.
# Example: api://cc31fd45-1eca-431f-a2c6-ba81cd4c5d50/.default
AUTH_SCOPE=
# Comma-separated lists (optional):
AUTH_ALLOWED_ROLES=

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AOC Events</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/style.css?v=7" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
</head>
@@ -29,8 +29,10 @@
<template x-for="src in sourceHealth" :key="src.source">
<div class="health-card">
<strong x-text="src.source"></strong>
<span class="pill" :class="src.status === 'healthy' ? 'pill--ok' : 'pill--warn'" x-text="src.status"></span>
<small x-text="src.last_fetch_time ? new Date(src.last_fetch_time).toLocaleString() : 'Never'"></small>
<span class="pill"
:class="src.status === 'healthy' ? 'pill--ok' : (src.status === 'error' ? 'pill--err' : 'pill--warn')"
x-text="src.status"></span>
<small x-text="src.last_fetch_time ? new Date(src.last_fetch_time).toLocaleString() : (src.last_attempt_time ? new Date(src.last_attempt_time).toLocaleString() : 'Never')"></small>
</div>
</template>
</div>
@@ -38,55 +40,73 @@
<section class="panel">
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
<label>
User (name/UPN)
<input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" x-model="filters.actor" />
<datalist id="actorOptions">
<template x-for="opt in options.actors" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
App / Service
<input name="service" type="text" placeholder="DirectoryManagement" list="serviceOptions" x-model="filters.service" />
<datalist id="serviceOptions">
<template x-for="opt in options.services" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
Search (raw/full-text)
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
</label>
<label>
Action (display name)
<input name="operation" type="text" placeholder="Add group member" list="operationOptions" x-model="filters.operation" />
<datalist id="operationOptions">
<template x-for="opt in options.operations" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
Action type (result)
<input name="result" type="text" placeholder="success / failure" list="resultOptions" x-model="filters.result" />
<datalist id="resultOptions">
<template x-for="opt in options.results" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
From
<input name="start" type="datetime-local" x-model="filters.start" />
</label>
<label>
To
<input name="end" type="datetime-local" x-model="filters.end" />
</label>
<label>
Limit
<input name="limit" type="number" min="1" max="500" value="100" x-model.number="filters.limit" />
</label>
<div class="actions">
<button type="submit">Apply filters</button>
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
<div class="filter-row">
<label>
User (name/UPN)
<input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" x-model="filters.actor" />
<datalist id="actorOptions">
<template x-for="opt in options.actors" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
Action (display name)
<input name="operation" type="text" placeholder="Add group member" list="operationOptions" x-model="filters.operation" />
<datalist id="operationOptions">
<template x-for="opt in options.operations" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
Action type (result)
<input name="result" type="text" placeholder="success / failure" list="resultOptions" x-model="filters.result" />
<datalist id="resultOptions">
<template x-for="opt in options.results" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
Limit
<input name="limit" type="number" min="1" max="500" value="100" x-model.number="filters.limit" />
</label>
</div>
<div class="filter-row">
<label>
From
<input name="start" type="datetime-local" x-model="filters.start" />
</label>
<label>
To
<input name="end" type="datetime-local" x-model="filters.end" />
</label>
<label class="span-2">
Search (raw/full-text)
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
</label>
</div>
<div class="filter-row filter-row--tall">
<div class="filter-group span-2">
<span>App / Service</span>
<div class="multi-select">
<div class="multi-select__actions">
<button type="button" class="link" @click="filters.selectedServices = [...options.services]">All</button>
<button type="button" class="link" @click="filters.selectedServices = []">None</button>
</div>
<div class="multi-select__options">
<template x-for="opt in options.services" :key="opt">
<label class="checkbox-label">
<input type="checkbox" :value="opt" x-model="filters.selectedServices" />
<span x-text="opt"></span>
</label>
</template>
</div>
</div>
</div>
</div>
<div class="filter-row actions-row">
<div class="actions">
<button type="submit">Apply filters</button>
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
</div>
</div>
</form>
</section>
@@ -98,35 +118,35 @@
</div>
<div id="status" class="status" aria-live="polite" x-text="statusText"></div>
<div id="events" class="events">
<template x-for="(e, idx) in events" :key="e._id || e.id || idx">
<template x-for="(evt, idx) in events" :key="evt._id || evt.id || idx">
<article class="event">
<div class="event__meta">
<span class="pill" x-text="e.display_category || e.service || '—'"></span>
<span class="pill" :class="(e.result || '').toLowerCase() === 'success' ? 'pill--ok' : 'pill--warn'" x-text="e.result || '—'"></span>
<span class="pill" x-text="evt.display_category || evt.service || '—'"></span>
<span class="pill" :class="['success','succeeded','ok','passed'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'"></span>
</div>
<h3 x-text="e.operation || '—'"></h3>
<p class="event__detail" x-show="e.display_summary"><strong>Summary:</strong> <span x-text="e.display_summary"></span></p>
<p class="event__detail"><strong x-text="e.display_actor_label || 'User'"></strong>: <span x-text="displayActor(e)"></span></p>
<p class="event__detail" x-show="e.actor_owner_names && e.actor_owner_names.length"><strong>App owners:</strong> <span x-text="(e.actor_owner_names || []).slice(0,3).join(', ')"></span></p>
<p class="event__detail"><strong>Target:</strong> <span x-text="displayTargets(e)"></span></p>
<p class="event__detail"><strong>When:</strong> <span x-text="e.timestamp ? new Date(e.timestamp).toLocaleString() : '—'"></span></p>
<h3 x-text="evt.operation || '—'"></h3>
<p class="event__detail" x-show="evt.display_summary"><strong>Summary:</strong> <span x-text="evt.display_summary"></span></p>
<p class="event__detail"><strong x-text="evt.display_actor_label || 'User'"></strong>: <span x-text="displayActor(evt)"></span></p>
<p class="event__detail" x-show="evt.actor_owner_names && evt.actor_owner_names.length"><strong>App owners:</strong> <span x-text="(evt.actor_owner_names || []).slice(0,3).join(', ')"></span></p>
<p class="event__detail"><strong>Target:</strong> <span x-text="displayTargets(evt)"></span></p>
<p class="event__detail"><strong>When:</strong> <span x-text="evt.timestamp ? new Date(evt.timestamp).toLocaleString() : '—'"></span></p>
<div class="event__tags" x-show="e.tags && e.tags.length">
<template x-for="tag in (e.tags || [])" :key="tag">
<div class="event__tags" x-show="evt.tags && evt.tags.length">
<template x-for="tag in (evt.tags || [])" :key="tag">
<span class="pill pill--tag" x-text="tag"></span>
</template>
</div>
<div class="event__comments" x-show="e.comments && e.comments.length">
<template x-for="c in (e.comments || [])" :key="c.timestamp + c.text">
<div class="event__comments" x-show="evt.comments && evt.comments.length">
<template x-for="c in (evt.comments || [])" :key="c.timestamp + c.text">
<p class="comment"><strong x-text="c.author"></strong>: <span x-text="c.text"></span> <small x-text="new Date(c.timestamp).toLocaleString()"></small></p>
</template>
</div>
<div class="event__actions">
<button class="ghost" @click="openModal(e)">View raw event</button>
<input type="text" placeholder="Add tag" @keydown.enter="addTag(e, $event.target.value); $event.target.value=''" />
<input type="text" placeholder="Add comment" @keydown.enter="addComment(e, $event.target.value); $event.target.value=''" />
<button type="button" class="ghost" @click="openModal(evt)">View raw event</button>
<input type="text" placeholder="Add tag" @keydown.enter="addTag(evt, $event.target.value); $event.target.value=''" />
<input type="text" placeholder="Add comment" @keydown.enter="addComment(evt, $event.target.value); $event.target.value=''" />
</div>
</article>
</template>
@@ -137,15 +157,15 @@
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
</div>
</section>
</div>
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
<div class="modal__content">
<div class="modal__header">
<h3 id="modalTitle">Raw Event</h3>
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
<div class="modal__content">
<div class="modal__header">
<h3 id="modalTitle">Raw Event</h3>
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
</div>
<pre id="modalBody" x-text="modalBody"></pre>
</div>
<pre id="modalBody" x-text="modalBody"></pre>
</div>
</div>
@@ -168,7 +188,7 @@
accessToken: null,
authScopes: [],
filters: {
actor: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100,
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100,
},
options: { actors: [], services: [], operations: [], results: [] },
@@ -186,7 +206,21 @@
},
pickToken(res) {
return res ? (res.accessToken || res.idToken || null) : null;
if (!res) return null;
const clientId = this.authConfig?.client_id;
// If accessToken is present and its audience matches our API, use it.
if (res.accessToken && clientId) {
try {
const base64 = res.accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
const payload = JSON.parse(atob(padded));
if (payload.aud === clientId) {
return res.accessToken;
}
} catch {}
}
// Fall back to idToken (always aud=clientId) or accessToken
return res.idToken || res.accessToken || null;
},
async initAuth() {
@@ -279,10 +313,13 @@
async loadEvents(cursor) {
this.currentCursor = cursor || null;
const params = new URLSearchParams();
['actor', 'service', 'operation', 'result', 'search'].forEach((key) => {
['actor', 'operation', 'result', 'search'].forEach((key) => {
const val = this.filters[key];
if (val) params.append(key, val);
});
if (this.filters.selectedServices && this.filters.selectedServices.length) {
this.filters.selectedServices.forEach((s) => params.append('services', s));
}
if (this.filters.start) {
const d = new Date(this.filters.start);
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
@@ -345,6 +382,9 @@
this.options.services = (opts.services || []).slice(0, 200);
this.options.operations = (opts.operations || []).slice(0, 200);
this.options.results = (opts.results || []).slice(0, 200);
if (!this.filters.selectedServices.length && this.options.services.length) {
this.filters.selectedServices = [...this.options.services];
}
} catch {}
},
@@ -377,12 +417,14 @@
},
clearFilters() {
this.filters = { actor: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100 };
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100 };
this.resetPagination();
this.loadEvents();
},
displayActor(e) {
const app = e.actor?.application || e.actor?.app;
if (app?.displayName) return app.displayName;
return e.actor_display ||
(e.actor_resolved?.name) ||
(e.actor?.user?.displayName && e.actor?.user?.userPrincipalName && e.actor?.user?.displayName !== e.actor?.user?.userPrincipalName
@@ -399,7 +441,18 @@
},
openModal(e) {
this.modalBody = JSON.stringify(e.raw || e, null, 2);
const seen = new WeakSet();
try {
this.modalBody = JSON.stringify(e.raw || e, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
}, 2);
} catch (err) {
this.modalBody = `Error serializing event:\n${err.message}\n\nEvent ID: ${e.id || 'N/A'}`;
}
this.modalOpen = true;
},

View File

@@ -91,12 +91,51 @@ button.ghost {
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
display: flex;
flex-direction: column;
gap: 14px;
}
.filters label {
.filter-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
align-items: start;
}
.filter-row .span-2 {
grid-column: span 2;
}
.filter-row--tall {
align-items: stretch;
}
.actions-row {
display: flex;
justify-content: flex-start;
}
@media (max-width: 900px) {
.filter-row {
grid-template-columns: repeat(2, 1fr);
}
.filter-row .span-2 {
grid-column: span 2;
}
}
@media (max-width: 480px) {
.filter-row {
grid-template-columns: 1fr;
}
.filter-row .span-2 {
grid-column: span 1;
}
}
.filters label,
.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
@@ -159,7 +198,7 @@ input {
.events {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
gap: 12px;
}
@@ -168,6 +207,8 @@ input {
border-radius: 14px;
padding: 14px;
background: rgba(255, 255, 255, 0.02);
min-width: 0;
overflow-wrap: break-word;
}
.event__meta {
@@ -175,16 +216,19 @@ input {
gap: 8px;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
.pill {
display: inline-block;
padding: 6px 10px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(125, 211, 252, 0.12);
border: 1px solid rgba(125, 211, 252, 0.4);
color: var(--text);
font-size: 12px;
max-width: 100%;
overflow-wrap: break-word;
}
.pill--ok {
@@ -197,8 +241,17 @@ input {
border-color: rgba(249, 115, 22, 0.5);
}
.pill--err {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.5);
}
.event h3 {
margin: 0 0 6px;
font-size: 17px;
line-height: 1.25;
overflow-wrap: break-word;
word-break: break-word;
}
.event__detail {
@@ -211,6 +264,71 @@ input {
margin-top: 10px;
}
.multi-select {
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
overflow: hidden;
}
.multi-select__actions {
display: flex;
gap: 12px;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
}
.multi-select__actions .link {
background: none;
border: none;
color: var(--accent-strong);
font-size: 12px;
padding: 0;
cursor: pointer;
font-weight: 500;
box-shadow: none;
}
.multi-select__actions .link:hover {
text-decoration: underline;
}
.multi-select__options {
max-height: 140px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.checkbox-label {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
gap: 8px;
padding: 6px 10px;
font-size: 13px;
color: var(--text);
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.checkbox-label:last-child {
border-bottom: none;
}
.checkbox-label:hover {
background: rgba(255, 255, 255, 0.04);
}
.checkbox-label input[type="checkbox"] {
accent-color: var(--accent-strong);
width: 14px;
height: 14px;
flex-shrink: 0;
margin: 0;
}
.modal {
position: fixed;
inset: 0;

View File

@@ -69,6 +69,17 @@ async def prometheus_middleware(request: Request, call_next):
return response
@app.middleware("http")
async def cache_control_middleware(request: Request, call_next):
response = await call_next(request)
# Prevent caching of HTML and API responses by default
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@app.middleware("http")
async def audit_middleware(request: Request, call_next):
response = await call_next(request)

View File

@@ -46,6 +46,7 @@ class FetchAuditLogsResponse(BaseModel):
class SourceHealthResponse(BaseModel):
source: str
last_fetch_time: str | None = None
last_attempt_time: str | None = None
status: str

View File

@@ -28,9 +28,11 @@ def _actor_display(actor: dict, resolved: dict = None, owners=None) -> str:
user = actor.get("user", {}) or {}
sp = actor.get("servicePrincipal", {}) or {}
app = actor.get("app", {}) or {}
application = actor.get("application", {}) or {}
upn = user.get("userPrincipalName") or user.get("mail")
display = user.get("displayName")
app_display = app.get("displayName")
app_display = app.get("displayName") or application.get("displayName")
app_id = app.get("id") or application.get("id")
if display and upn and display != upn:
return f"{display} ({upn})"
@@ -41,6 +43,7 @@ def _actor_display(actor: dict, resolved: dict = None, owners=None) -> str:
or app_display
or sp.get("displayName")
or sp.get("appId")
or app_id
or actor.get("ipAddress")
or user.get("id")
or sp.get("id")
@@ -164,7 +167,8 @@ def normalize_event(e):
display_conf = display_mapping.get(category) or display_mapping.get("default", {})
actor_field_pref = display_conf.get("actor_field", "actor_display")
actor_label_text = display_conf.get("actor_label", "User")
default_actor_label = "Application" if (actor.get("application") or actor.get("app")) else "User"
actor_label_text = display_conf.get("actor_label", default_actor_label)
if actor_field_pref == "actor_upn" and actor_upn:
display_actor_value = actor_upn

View File

@@ -34,6 +34,7 @@ def _decode_cursor(cursor: str) -> tuple[str, str]:
@router.get("/events", response_model=PaginatedEventResponse)
def list_events(
service: str | None = None,
services: list[str] | None = Query(default=None),
actor: str | None = None,
operation: str | None = None,
result: str | None = None,
@@ -48,6 +49,8 @@ def list_events(
if service:
filters.append({"service": service})
if services:
filters.append({"service": {"$in": services}})
if actor:
actor_safe = re.escape(actor)
filters.append(

View File

@@ -31,12 +31,13 @@ def run_fetch(hours: int = 168):
try:
since = get_watermark(source_key)
result = fn(since=since) if since else fn(hours=window)
set_watermark(source_key, now)
set_watermark(source_key, now, status="healthy")
track_fetch(source_key, len(result))
return result
except Exception as exc:
errors.append(f"{label}: {exc}")
track_fetch_error(source_key)
set_watermark(source_key, now, status="error")
return []
finally:
track_fetch_duration(source_key, time.time() - start_time)

View File

@@ -15,16 +15,21 @@ def source_health():
results = []
for source in SOURCES:
doc = watermarks_collection.find_one({"source": source})
if doc and doc.get("last_fetch_time"):
if doc:
status = doc.get("status")
if not status:
status = "healthy" if doc.get("last_fetch_time") else "unknown"
results.append({
"source": source,
"last_fetch_time": doc["last_fetch_time"],
"status": "healthy",
"last_fetch_time": doc.get("last_fetch_time"),
"last_attempt_time": doc.get("last_attempt_time"),
"status": status,
})
else:
results.append({
"source": source,
"last_fetch_time": None,
"last_attempt_time": None,
"status": "unknown",
})
return results

View File

@@ -45,20 +45,27 @@ def _normalize_intune(e: dict) -> dict:
actor = e.get("actor", {}) or {}
target = e.get("resources", [{}])[0] if e.get("resources") else {}
initiated_by: dict = {"user": {}}
if actor.get("auditActorType") == "Application" or actor.get("applicationDisplayName"):
initiated_by["application"] = {
"id": actor.get("applicationId"),
"displayName": actor.get("applicationDisplayName"),
}
else:
initiated_by["user"] = {
"id": actor.get("userId"),
"userPrincipalName": actor.get("userPrincipalName"),
"displayName": actor.get("userName"),
"ipAddress": actor.get("ipAddress"),
}
return {
"id": e.get("id"),
"activityDateTime": e.get("activityDateTime"),
"category": e.get("category") or "Intune",
"activityDisplayName": e.get("activity") or e.get("activityType"),
"result": e.get("activityResult") or e.get("result"),
"initiatedBy": {
"user": {
"id": actor.get("userId"),
"userPrincipalName": actor.get("userPrincipalName"),
"displayName": actor.get("userName"),
"ipAddress": actor.get("ipAddress"),
}
},
"initiatedBy": initiated_by,
"targetResources": [
{
"id": target.get("id"),

View File

@@ -73,6 +73,42 @@ def test_list_events_filter_by_service(client, mock_events_collection):
assert data["items"][0]["service"] == "Exchange"
def test_list_events_filter_by_services(client, mock_events_collection):
mock_events_collection.insert_one({
"id": "evt-1",
"timestamp": datetime.now(UTC).isoformat(),
"service": "Exchange",
"operation": "Update",
"result": "success",
"actor_display": "Alice",
"raw_text": "",
})
mock_events_collection.insert_one({
"id": "evt-2",
"timestamp": datetime.now(UTC).isoformat(),
"service": "Directory",
"operation": "Add",
"result": "success",
"actor_display": "Bob",
"raw_text": "",
})
mock_events_collection.insert_one({
"id": "evt-3",
"timestamp": datetime.now(UTC).isoformat(),
"service": "Teams",
"operation": "Delete",
"result": "success",
"actor_display": "Charlie",
"raw_text": "",
})
response = client.get("/api/events?service=Exchange&service=Directory")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
returned_services = {item["service"] for item in data["items"]}
assert returned_services == {"Exchange", "Directory"}
def test_list_events_page_size_validation(client):
response = client.get("/api/events?page_size=0")
assert response.status_code == 422

View File

@@ -9,10 +9,15 @@ def get_watermark(source: str) -> str | None:
return doc.get("last_fetch_time") if doc else None
def set_watermark(source: str, timestamp: str):
"""Persist the latest successful fetch timestamp for a source."""
def set_watermark(source: str, timestamp: str, status: str | None = None):
"""Persist the latest fetch attempt timestamp and optional status for a source."""
doc: dict = {"last_attempt_time": timestamp}
if status == "healthy":
doc["last_fetch_time"] = timestamp
if status:
doc["status"] = status
watermarks_collection.update_one(
{"source": source},
{"$set": {"last_fetch_time": timestamp}},
{"$set": doc},
upsert=True,
)