Merge pull request #176 from christianlouis/codex/m10-forensic-failure-views
[codex] Add forensic failure views
This commit is contained in:
@@ -70,7 +70,7 @@ have no working implementation in the codebase yet.
|
||||
- [x] Forensic report parsing
|
||||
- [ ] Failure sample analysis
|
||||
- [x] PII redaction options
|
||||
- [ ] Detailed authentication failure views
|
||||
- [x] Detailed authentication failure views
|
||||
|
||||
### User Authentication & Multi-User Support
|
||||
- **Documented in**: README.md ("Built-in authentication via FastAPI Users"),
|
||||
|
||||
@@ -129,6 +129,9 @@ async def upload_forensic_report(
|
||||
@router.get("", response_model=ForensicListResponse)
|
||||
async def list_forensic_reports(
|
||||
domain: Optional[str] = Query(default=None),
|
||||
source_ip: Optional[str] = Query(default=None),
|
||||
auth_failure: Optional[str] = Query(default=None),
|
||||
delivery_result: Optional[str] = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -141,6 +144,12 @@ async def list_forensic_reports(
|
||||
query = query.outerjoin(Domain).filter(
|
||||
(Domain.name == normalized) | (ForensicReport.reported_domain == normalized)
|
||||
)
|
||||
if source_ip:
|
||||
query = query.filter(ForensicReport.source_ip == source_ip.strip())
|
||||
if auth_failure:
|
||||
query = query.filter(ForensicReport.auth_failure == auth_failure.strip().lower())
|
||||
if delivery_result:
|
||||
query = query.filter(ForensicReport.delivery_result == delivery_result.strip().lower())
|
||||
|
||||
total = query.count()
|
||||
rows = (
|
||||
|
||||
@@ -551,6 +551,22 @@ async def report_detail(request: Request, report_id: str):
|
||||
return templates.TemplateResponse(request, "report_detail.html", {"report_id": report_id})
|
||||
|
||||
|
||||
@app.get("/forensics", response_class=HTMLResponse)
|
||||
async def forensic_reports(request: Request):
|
||||
"""View DMARC forensic authentication failure reports."""
|
||||
return templates.TemplateResponse(request, "forensic_reports.html")
|
||||
|
||||
|
||||
@app.get("/forensics/{report_id}", response_class=HTMLResponse)
|
||||
async def forensic_report_detail(request: Request, report_id: int):
|
||||
"""View detailed information for a specific forensic report."""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"forensic_report_detail.html",
|
||||
{"report_id": report_id},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
return templates.TemplateResponse(request, "settings.html")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content %}
|
||||
|
||||
{% block title %}DMARQ - Forensic Report Detail{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto py-4" x-data="forensicReportDetailApp({{ report_id }})" x-init="init()">
|
||||
<nav class="mb-4 text-sm">
|
||||
<ol class="flex items-center space-x-2">
|
||||
<li><a href="/" class="hover:text-primary">Dashboard</a></li>
|
||||
<li><span class="text-base-content/40 px-2">/</span></li>
|
||||
<li><a href="/forensics" class="hover:text-primary">Forensics</a></li>
|
||||
<li><span class="text-base-content/40 px-2">/</span></li>
|
||||
<li><span class="font-medium" x-text="reportId"></span></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<template x-if="loading">
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && error">
|
||||
<div class="alert alert-error mb-6" x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && !error && report">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Forensic Investigation</h1>
|
||||
<p class="text-sm text-base-content/70 mt-1">
|
||||
<span x-text="report.report_id"></span>
|
||||
<span>•</span>
|
||||
<span x-text="report.domain || report.reported_domain || 'unknown domain'"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/forensics" class="btn btn-outline btn-sm">Back to Forensics</a>
|
||||
<a class="btn btn-outline btn-sm" :href="'/domains/' + encodeURIComponent(report.domain || report.reported_domain)">Domain</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{% call card() %}
|
||||
{% call card_header() %}{% call card_title() %}Failure{% endcall %}{% endcall %}
|
||||
{% call card_content() %}<div class="text-2xl font-semibold uppercase" x-text="report.auth_failure || 'unknown'"></div>{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card() %}
|
||||
{% call card_header() %}{% call card_title() %}Delivery{% endcall %}{% endcall %}
|
||||
{% call card_content() %}<div class="text-2xl font-semibold uppercase" x-text="report.delivery_result || 'unknown'"></div>{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card() %}
|
||||
{% call card_header() %}{% call card_title() %}Source IP{% endcall %}{% endcall %}
|
||||
{% call card_content() %}<div class="text-xl font-mono" x-text="report.source_ip || '-'"></div>{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card() %}
|
||||
{% call card_header() %}{% call card_title() %}Arrival{% endcall %}{% endcall %}
|
||||
{% call card_content() %}<div class="text-sm font-medium" x-text="formatDate(report.arrival_date || report.processed_at)"></div>{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}Message Identity{% endcall %}
|
||||
{% call card_description() %}Redacted metadata extracted from the reported failure sample{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<template x-for="item in identityFields" :key="item.label">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-base-content/60" x-text="item.label"></p>
|
||||
<p class="font-medium break-words" :class="item.mono ? 'font-mono text-sm' : ''" x-text="item.value || '-'"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}Authentication Results{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<pre class="bg-base-200 rounded p-4 text-sm whitespace-pre-wrap break-words" x-text="report.authentication_results || 'No Authentication-Results header was included.'"></pre>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}Feedback Headers{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<template x-if="feedbackHeaderEntries.length === 0">
|
||||
<p class="text-base-content/60">No additional feedback headers were included.</p>
|
||||
</template>
|
||||
<template x-for="[key, value] in feedbackHeaderEntries" :key="key">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-base-content/60" x-text="labelize(key)"></p>
|
||||
<p class="font-medium break-words" x-text="value || '-'"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function forensicReportDetailApp(reportId) {
|
||||
return {
|
||||
reportId,
|
||||
report: null,
|
||||
loading: false,
|
||||
error: '',
|
||||
async init() {
|
||||
await this.fetchReport();
|
||||
},
|
||||
get identityFields() {
|
||||
if (!this.report) return [];
|
||||
return [
|
||||
{ label: 'Reported Domain', value: this.report.reported_domain || this.report.domain },
|
||||
{ label: 'Reporter', value: this.report.source_email },
|
||||
{ label: 'Original Mail From', value: this.report.original_mail_from },
|
||||
{ label: 'Original From', value: this.report.original_from },
|
||||
{ label: 'Original To', value: this.report.original_to },
|
||||
{ label: 'Original Subject', value: this.report.original_subject },
|
||||
{ label: 'Original Date', value: this.report.original_date },
|
||||
{ label: 'Message Hash', value: this.report.original_message_id, mono: true },
|
||||
{ label: 'Feedback Type', value: this.report.feedback_type },
|
||||
{ label: 'Reporter Agent', value: this.report.user_agent },
|
||||
];
|
||||
},
|
||||
get feedbackHeaderEntries() {
|
||||
return Object.entries(this.report?.feedback_headers || {});
|
||||
},
|
||||
async fetchReport() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/forensics/${this.reportId}`);
|
||||
if (!response.ok) throw new Error('Forensic report not found');
|
||||
this.report = await response.json();
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Unable to load forensic report';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
},
|
||||
labelize(value) {
|
||||
return String(value || '').replaceAll('_', ' ').replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,268 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content %}
|
||||
{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %}
|
||||
|
||||
{% block title %}DMARQ - Forensic Reports{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto py-4" x-data="forensicReportsApp()" x-init="init()">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Forensic Reports</h1>
|
||||
<p class="text-sm text-base-content/70 mt-1">Individual DMARC authentication failure reports</p>
|
||||
</div>
|
||||
<a href="/settings" class="btn btn-outline btn-sm">Privacy Settings</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{% call card() %}
|
||||
{% call card_header() %}{% call card_title() %}Incidents{% endcall %}{% endcall %}
|
||||
{% call card_content() %}
|
||||
<div class="text-2xl font-semibold" x-text="summary.total"></div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card() %}
|
||||
{% call card_header() %}{% call card_title() %}DKIM Failures{% endcall %}{% endcall %}
|
||||
{% call card_content() %}
|
||||
<div class="text-2xl font-semibold text-error" x-text="summary.dkim"></div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card() %}
|
||||
{% call card_header() %}{% call card_title() %}SPF Failures{% endcall %}{% endcall %}
|
||||
{% call card_content() %}
|
||||
<div class="text-2xl font-semibold text-warning" x-text="summary.spf"></div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card() %}
|
||||
{% call card_header() %}{% call card_title() %}Rejected{% endcall %}{% endcall %}
|
||||
{% call card_content() %}
|
||||
<div class="text-2xl font-semibold" x-text="summary.rejected"></div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[1fr_22rem] gap-6 mb-6">
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}Filters{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<label class="form-control">
|
||||
<span class="label-text font-medium mb-1">Domain</span>
|
||||
<select class="select select-bordered w-full" x-model="filters.domain" @change="fetchReports()">
|
||||
<option value="">All domains</option>
|
||||
<template x-for="domain in domains" :key="domain">
|
||||
<option :value="domain" x-text="domain"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-control">
|
||||
<span class="label-text font-medium mb-1">Failure</span>
|
||||
<select class="select select-bordered w-full" x-model="filters.authFailure" @change="fetchReports()">
|
||||
<option value="">Any failure</option>
|
||||
<option value="dkim">DKIM</option>
|
||||
<option value="spf">SPF</option>
|
||||
<option value="dmarc">DMARC</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-control">
|
||||
<span class="label-text font-medium mb-1">Result</span>
|
||||
<select class="select select-bordered w-full" x-model="filters.deliveryResult" @change="fetchReports()">
|
||||
<option value="">Any result</option>
|
||||
<option value="reject">Reject</option>
|
||||
<option value="quarantine">Quarantine</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-control">
|
||||
<span class="label-text font-medium mb-1">Search</span>
|
||||
<input class="input input-bordered w-full" x-model.debounce.200ms="filters.search" placeholder="IP, sender, subject">
|
||||
</label>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}Import Sample{% endcall %}
|
||||
{% call card_description() %}Upload a forensic .eml or .txt file{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<form class="space-y-3" @submit.prevent="uploadReport()">
|
||||
<input type="file" class="file-input file-input-bordered w-full" accept=".eml,.txt,message/rfc822,text/plain" @change="selectedFile = $event.target.files[0] || null">
|
||||
<button class="btn btn-primary w-full" type="submit" :disabled="uploading || !selectedFile">
|
||||
<span x-show="!uploading">Upload Forensic Report</span>
|
||||
<span x-show="uploading" class="loading loading-spinner loading-sm"></span>
|
||||
</button>
|
||||
<p class="text-sm" :class="uploadError ? 'text-error' : 'text-success'" x-show="uploadMessage" x-text="uploadMessage"></p>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
{% call card_title() %}Authentication Failures{% endcall %}
|
||||
{% call card_description() %}
|
||||
Showing <span x-text="filteredReports.length"></span> of <span x-text="total"></span> reports
|
||||
{% endcall %}
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" @click="resetFilters()">Reset</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<template x-if="loading">
|
||||
<div class="py-12 text-center"><span class="loading loading-spinner loading-lg"></span></div>
|
||||
</template>
|
||||
<template x-if="!loading && error">
|
||||
<div class="alert alert-error" x-text="error"></div>
|
||||
</template>
|
||||
<template x-if="!loading && !error">
|
||||
{% call table() %}
|
||||
{% call thead() %}
|
||||
{% call tr() %}
|
||||
{% call th() %}Arrival{% endcall %}
|
||||
{% call th() %}Domain{% endcall %}
|
||||
{% call th() %}Source IP{% endcall %}
|
||||
{% call th() %}Failure{% endcall %}
|
||||
{% call th() %}Result{% endcall %}
|
||||
{% call th() %}Original From{% endcall %}
|
||||
{% call th() %}Subject{% endcall %}
|
||||
{% call th("text-right") %}Actions{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call tbody() %}
|
||||
<template x-if="filteredReports.length === 0">
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-8 text-base-content/60">No forensic reports match the current filters.</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="report in filteredReports" :key="report.id">
|
||||
{% call tr() %}
|
||||
{% call td() %}<span x-text="formatDate(report.arrival_date || report.processed_at)"></span>{% endcall %}
|
||||
{% call td() %}<a class="link link-hover font-medium" :href="'/domains/' + encodeURIComponent(report.domain || report.reported_domain)" x-text="report.domain || report.reported_domain || 'unknown'"></a>{% endcall %}
|
||||
{% call td() %}<span class="font-mono text-sm" x-text="report.source_ip || '-'"></span>{% endcall %}
|
||||
{% call td() %}<span class="badge badge-error badge-outline uppercase" x-text="report.auth_failure || 'unknown'"></span>{% endcall %}
|
||||
{% call td() %}<span class="badge badge-ghost uppercase" x-text="report.delivery_result || 'unknown'"></span>{% endcall %}
|
||||
{% call td() %}<span class="block max-w-48 truncate" x-text="report.original_from || report.original_mail_from || '-'"></span>{% endcall %}
|
||||
{% call td() %}<span class="block max-w-64 truncate" x-text="report.original_subject || '-'"></span>{% endcall %}
|
||||
{% call td("text-right") %}
|
||||
<a class="btn btn-outline btn-sm" :href="'/forensics/' + report.id">Investigate</a>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</template>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function forensicReportsApp() {
|
||||
return {
|
||||
loading: false,
|
||||
uploading: false,
|
||||
error: '',
|
||||
uploadMessage: '',
|
||||
uploadError: false,
|
||||
selectedFile: null,
|
||||
reports: [],
|
||||
domainOptions: [],
|
||||
total: 0,
|
||||
filters: {
|
||||
domain: '',
|
||||
authFailure: '',
|
||||
deliveryResult: '',
|
||||
search: '',
|
||||
},
|
||||
init() {
|
||||
this.fetchReports();
|
||||
},
|
||||
get domains() {
|
||||
return this.domainOptions;
|
||||
},
|
||||
get filteredReports() {
|
||||
const search = this.filters.search.trim().toLowerCase();
|
||||
if (!search) return this.reports;
|
||||
return this.reports.filter((report) => [
|
||||
report.source_ip,
|
||||
report.original_from,
|
||||
report.original_mail_from,
|
||||
report.original_subject,
|
||||
report.authentication_results,
|
||||
report.report_id,
|
||||
].some((value) => String(value || '').toLowerCase().includes(search)));
|
||||
},
|
||||
get summary() {
|
||||
return {
|
||||
total: this.total,
|
||||
dkim: this.reports.filter((report) => report.auth_failure === 'dkim').length,
|
||||
spf: this.reports.filter((report) => report.auth_failure === 'spf').length,
|
||||
rejected: this.reports.filter((report) => report.delivery_result === 'reject').length,
|
||||
};
|
||||
},
|
||||
async fetchReports() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
const params = new URLSearchParams({ page_size: '200' });
|
||||
if (this.filters.domain) params.set('domain', this.filters.domain);
|
||||
if (this.filters.authFailure) params.set('auth_failure', this.filters.authFailure);
|
||||
if (this.filters.deliveryResult) params.set('delivery_result', this.filters.deliveryResult);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/forensics?${params.toString()}`);
|
||||
if (!response.ok) throw new Error('Unable to load forensic reports');
|
||||
const data = await response.json();
|
||||
this.reports = data.reports || [];
|
||||
this.total = data.total || 0;
|
||||
if (!this.filters.domain) {
|
||||
this.domainOptions = [...new Set(this.reports.map((report) => report.domain || report.reported_domain).filter(Boolean))].sort();
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Unable to load forensic reports';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async uploadReport() {
|
||||
if (!this.selectedFile) return;
|
||||
this.uploading = true;
|
||||
this.uploadMessage = '';
|
||||
this.uploadError = false;
|
||||
const payload = new FormData();
|
||||
payload.append('file', this.selectedFile);
|
||||
try {
|
||||
const response = await fetch('/api/v1/forensics/upload', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.detail || 'Upload failed');
|
||||
this.uploadMessage = data.message || 'Forensic report imported.';
|
||||
this.selectedFile = null;
|
||||
await this.fetchReports();
|
||||
} catch (err) {
|
||||
this.uploadError = true;
|
||||
this.uploadMessage = err.message || 'Upload failed';
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
},
|
||||
resetFilters() {
|
||||
this.filters = { domain: '', authFailure: '', deliveryResult: '', search: '' };
|
||||
this.fetchReports();
|
||||
},
|
||||
formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -38,6 +38,7 @@
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/domains">Domains</a></li>
|
||||
<li><a href="/reports">Reports</a></li>
|
||||
<li><a href="/forensics">Forensics</a></li>
|
||||
<li><a href="/upload">Upload</a></li>
|
||||
<li><a href="/mail-sources">Mail Sources</a></li>
|
||||
<li><a href="/operations">Health</a></li>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.api_v1.endpoints import forensics as forensics_endpoint
|
||||
@@ -63,6 +64,32 @@ def test_list_and_detail_forensic_reports(authed_client):
|
||||
assert detail_response.json()["reported_domain"] == "example.com"
|
||||
|
||||
|
||||
def test_list_forensic_reports_filters_failure_fields(authed_client, db_session):
|
||||
first = ForensicParser.parse_bytes(SAMPLE_FORENSIC_EMAIL)
|
||||
second = dict(first)
|
||||
second.update(
|
||||
{
|
||||
"report_id": "ruf-spf-filter-test",
|
||||
"reported_domain": "example.net",
|
||||
"source_ip": "198.51.100.77",
|
||||
"auth_failure": "spf",
|
||||
"delivery_result": "none",
|
||||
}
|
||||
)
|
||||
save_forensic_report(db_session, first)
|
||||
save_forensic_report(db_session, second)
|
||||
db_session.commit()
|
||||
|
||||
response = authed_client.get(
|
||||
"/api/v1/forensics?auth_failure=spf&delivery_result=none&source_ip=198.51.100.77"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["reports"][0]["report_id"] == "ruf-spf-filter-test"
|
||||
|
||||
|
||||
def test_forensic_api_applies_configured_redaction_policy(authed_client, db_session):
|
||||
authed_client.post(
|
||||
"/api/v1/forensics/upload",
|
||||
@@ -175,6 +202,22 @@ def test_forensic_detail_returns_404(authed_client):
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_forensic_html_pages_render():
|
||||
from app.core.logto import SESSION_COOKIE, create_session_token # noqa: PLC0415
|
||||
from app.main import app as main_app # noqa: PLC0415
|
||||
|
||||
cookies = {SESSION_COOKIE: create_session_token(user_id=1)}
|
||||
with TestClient(main_app) as c:
|
||||
list_response = c.get("/forensics", cookies=cookies)
|
||||
detail_response = c.get("/forensics/123", cookies=cookies)
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert "Forensic Reports" in list_response.text
|
||||
assert "Authentication Failures" in list_response.text
|
||||
assert detail_response.status_code == 200
|
||||
assert "Forensic Investigation" in detail_response.text
|
||||
|
||||
|
||||
def test_save_forensic_report_duplicate_and_invalid_domain_paths(db_session):
|
||||
parsed = ForensicParser.parse_bytes(SAMPLE_FORENSIC_EMAIL)
|
||||
parsed["feedback_headers"] = {"identity_alignment": "dkim"}
|
||||
|
||||
+1
-1
@@ -174,9 +174,9 @@ Delivered:
|
||||
- Configure forensic report redaction for balanced, domain-only, and strict views.
|
||||
- Keep forensic reports out of aggregate report statistics and ReportStore rollups.
|
||||
- Expose authenticated forensic upload/list/detail APIs.
|
||||
- Provide dedicated forensic report list/detail views for authentication failure investigation.
|
||||
|
||||
Planned:
|
||||
- Add a dedicated forensic report view.
|
||||
- Add richer failure investigation workflows.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
Reference in New Issue
Block a user