Implemented audit logging

This commit is contained in:
2026-10-22 12:31:37 +02:00
parent 92891438f6
commit 4dd0899209
12 changed files with 3477 additions and 90 deletions
@@ -1,11 +1,39 @@
using PostFundManagement.Application.Services.Shared;
using System.Reflection;
using System.Security.Claims;
using System.Text.Json;
using Castle.DynamicProxy;
using FluentResults;
using Microsoft.AspNetCore.Http;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain.Attributes;
namespace PostFundManagement.Application.Interceptors;
public sealed class AuditInterceptor(AuditService auditService, IHttpContextAccessor httpContextAccessor) : IAsyncInterceptor
{
public void InterceptSynchronous(IInvocation invocation) => invocation.Proceed();
public void InterceptSynchronous(IInvocation invocation)
{
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
if (auditAttr == null)
{
invocation.Proceed();
return;
}
try
{
invocation.Proceed();
AuditAction(invocation, invocation.ReturnValue, null);
}
catch (Exception ex)
{
AuditAction(invocation, null, ex);
throw;
}
}
public void InterceptAsynchronous(IInvocation invocation) =>
invocation.ReturnValue = InternalInterceptAsynchronous(invocation);
@@ -13,49 +41,177 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
public void InterceptAsynchronous<TResult>(IInvocation invocation) =>
invocation.ReturnValue = InternalInterceptAsynchronous<TResult>(invocation);
private static async Task InternalInterceptAsynchronous(IInvocation invocation)
private async Task InternalInterceptAsynchronous(IInvocation invocation)
{
invocation.Proceed();
var task = (Task)invocation.ReturnValue;
await task.ConfigureAwait(false);
}
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
{
invocation.Proceed();
var task = (Task<TResult>)invocation.ReturnValue;
var resultObj = await task.ConfigureAwait(false);
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
if (auditAttr == null) return resultObj;
if (resultObj is IResultBase result && result.IsSuccess)
if (auditAttr == null)
{
try
{
var action = ResolveActionName(method.Name);
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
var changes = BuildPayloadSummary(invocation.Method.GetParameters(), invocation.Arguments);
var performedBy = ResolveUserId();
invocation.Proceed();
var t = (Task)invocation.ReturnValue;
await t.ConfigureAwait(false);
return;
}
try
{
invocation.Proceed();
var task = (Task)invocation.ReturnValue;
await task.ConfigureAwait(false);
await AuditActionAsync(invocation, null, null).ConfigureAwait(false);
}
catch (Exception ex)
{
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
throw;
}
}
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
{
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
if (auditAttr == null)
{
invocation.Proceed();
var t = (Task<TResult>)invocation.ReturnValue;
return await t.ConfigureAwait(false);
}
TResult resultObj;
try
{
invocation.Proceed();
var task = (Task<TResult>)invocation.ReturnValue;
resultObj = await task.ConfigureAwait(false);
}
catch (Exception ex)
{
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
throw;
}
await AuditActionAsync(invocation, resultObj, null).ConfigureAwait(false);
return resultObj;
}
private async Task AuditActionAsync(IInvocation invocation, object? resultObj, Exception? exception)
{
try
{
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var action = ResolveActionName(method.Name);
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
var changes = BuildPayloadSummary(method.GetParameters(), invocation.Arguments);
var performedBy = ResolveUserId();
var tenantId = ResolveTenantId(invocation.Arguments);
if (exception != null)
{
var errorDetails = $"[Exception] {exception.GetType().Name}: {exception.Message}\n{exception.StackTrace}";
await auditService.LogActionAsync(
entityId: entityId,
entityName: entityName,
action: action,
changes: changes,
performedBy: performedBy).ConfigureAwait(false);
performedBy: performedBy,
error: errorDetails,
tenantId: tenantId).ConfigureAwait(false);
return;
}
catch { /* eat all exceptions, don't interrupt program flow */ }
}
return resultObj;
if (resultObj is IResultBase result)
{
if (result.IsSuccess)
{
await auditService.LogActionAsync(
entityId: entityId,
entityName: entityName,
action: action,
changes: changes,
performedBy: performedBy,
error: null,
tenantId: tenantId).ConfigureAwait(false);
}
else if (result.IsFailed)
{
var errorMessages = new List<string>();
if (result.Reasons != null)
{
foreach (var reason in result.Reasons)
{
if (reason is IExceptionalError exceptionalError)
errorMessages.Add($"{exceptionalError.Message} ({exceptionalError.Exception.GetType().Name}: {exceptionalError.Exception.Message})\n{exceptionalError.Exception.StackTrace}");
else if (reason is IError error)
errorMessages.Add(error.Message);
}
}
var rawErrorType = result.Reasons?.FirstOrDefault()?.GetType().Name.Replace("Error", string.Empty);
var errorType = string.IsNullOrWhiteSpace(rawErrorType) || rawErrorType == "Reason" ? "Error" : rawErrorType;
var errorsString = string.Join("; ", errorMessages);
var errorDetails = $"[{errorType}] {errorsString}";
await auditService.LogActionAsync(
entityId: entityId,
entityName: entityName,
action: action,
changes: changes,
performedBy: performedBy,
error: errorDetails,
tenantId: tenantId).ConfigureAwait(false);
}
}
else
{
await auditService.LogActionAsync(
entityId: entityId,
entityName: entityName,
action: action,
changes: changes,
performedBy: performedBy,
error: null,
tenantId: tenantId).ConfigureAwait(false);
}
}
catch
{
/* eat all exceptions, don't interrupt program flow */
}
}
private void AuditAction(IInvocation invocation, object? resultObj, Exception? exception)
{
try
{
AuditActionAsync(invocation, resultObj, exception).GetAwaiter().GetResult();
}
catch
{
/* eat all exceptions, don't interrupt program flow */
}
}
private static long? ResolveTenantId(object[] args)
{
foreach (var arg in args)
{
if (arg == null || arg is CancellationToken or IFormFile) continue;
var prop = arg.GetType().GetProperty("TenantId");
if (prop != null)
{
var val = prop.GetValue(arg);
if (val is long l && l > 0) return l;
if (val is int i && i > 0) return i;
if (val is string s && long.TryParse(s, out var parsed) && parsed > 0) return parsed;
}
}
return null;
}
private static string ResolveActionName(string methodName) =>
@@ -73,7 +229,7 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
{
var genericDef = returnType.GetGenericTypeDefinition();
if (genericDef == typeof(Task<>) || genericDef == typeof(Task<>))
if (genericDef == typeof(Task<>))
innerType = returnType.GetGenericArguments()[0];
}
@@ -208,4 +364,4 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
return Guid.TryParse(sub, out var guid) ? guid : Guid.Empty;
}
}
}
@@ -1,4 +1,4 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
@@ -10,27 +10,22 @@ namespace PostFundManagement.Application.Services.Shared;
public sealed class AuditService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContextService) : IService
{
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var pagination = new Pagination { Page = 1, PageSize = 500, Index = 1 };
var logs = await context.AuditLogs
.OrderByDescending(o => o.Id)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(logs.Select(p => p.Map()).ToArray());
}));
await GetAuditLogsAsync(new Pagination { Page = 1, PageSize = 500, Index = 1 }, cancellationToken);
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var logs = await context.AuditLogs
var query = context.AuditLogs.AsQueryable();
if (!tenantContextService.IsAdmin)
{
var currentTenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L;
query = query.Where(o => o.TenantId == currentTenantId);
}
var logs = await query
.OrderByDescending(o => o.Id)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
@@ -39,11 +34,23 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
return Result.Ok(logs.Select(p => p.Map()).ToArray());
}));
public async Task<Result> LogActionAsync(string entityId, string entityName, string action, string[] changes, Guid performedBy, CancellationToken cancellationToken = default) =>
public async Task<Result> LogActionAsync(
string entityId,
string entityName,
string action,
string[] changes,
Guid performedBy,
string? error = null,
long? tenantId = null,
CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var resolvedTenantId = (tenantId.HasValue && tenantId.Value > 0)
? tenantId.Value
: (tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L);
context.Add(new Domain.Entities.AuditLog
{
EntityId = entityId,
@@ -52,7 +59,8 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
EntityName = entityName,
PerformedBy = performedBy,
Timestamp = DateTime.UtcNow,
TenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : null,
TenantId = resolvedTenantId,
Error = error,
});
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New);
@@ -20,6 +20,8 @@ import {
Award as AwardIcon,
FileText,
AlertCircle,
AlertTriangle,
AlertOctagon,
Layers,
Activity,
Calendar,
@@ -370,8 +372,14 @@ export const SettingsPage: React.FC = () => {
const [auditSearchTerm, setAuditSearchTerm] = useState('');
const [auditEntityFilter, setAuditEntityFilter] = useState<string>('all');
const [auditActionFilter, setAuditActionFilter] = useState<string>('all');
const [auditTypeFilter, setAuditTypeFilter] = useState<'all' | 'audits' | 'exceptions'>('all');
const [auditPage, setAuditPage] = useState(1);
const [auditPageSize, setAuditPageSize] = useState(10);
const [selectedAuditLog, setSelectedAuditLog] = useState<AuditLog | null>(null);
const [isAuditDetailModalOpen, setIsAuditDetailModalOpen] = useState(false);
const [selectedExceptionLog, setSelectedExceptionLog] = useState<AuditLog | null>(null);
const [isExceptionDetailModalOpen, setIsExceptionDetailModalOpen] = useState(false);
const [copiedExceptionTrace, setCopiedExceptionTrace] = useState(false);
const [copiedAuditPayload, setCopiedAuditPayload] = useState(false);
const [expandedAuditLogIds, setExpandedAuditLogIds] = useState<Record<string | number, boolean>>({});
const [copiedUserId, setCopiedUserId] = useState<string | number | null>(null);
@@ -402,11 +410,14 @@ export const SettingsPage: React.FC = () => {
isFetching: isFetchingAuditLogs,
} = useQuery({
queryKey: ['audit-logs'],
queryFn: () => getAuditLogs(1, 100),
queryFn: () => getAuditLogs(1, 500),
});
const isLogException = (log: AuditLog) => Boolean(log.error || log.isError);
const auditExportColumns: ExportColumn<AuditLog>[] = [
{ header: 'Event ID', key: 'id' },
{ header: 'Event Type', key: 'error', format: (val, row) => (val || row?.isError ? 'EXCEPTION' : 'AUDIT') },
{ header: 'Timestamp', key: 'timestamp', format: (val) => formatDate(val) },
{ header: 'Action', key: 'action', format: (val) => val || '—' },
{ header: 'Entity Name', key: 'entityName', format: (val) => val || '—' },
@@ -421,10 +432,11 @@ export const SettingsPage: React.FC = () => {
},
{ header: 'Tenant ID', key: 'tenantId', format: (val) => (val ? `Tenant #${val}` : '—') },
{
header: 'Changes',
header: 'Changes / Arguments',
key: 'changes',
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
},
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
];
const uniqueAuditEntities = Array.from(
@@ -435,6 +447,13 @@ export const SettingsPage: React.FC = () => {
);
const filteredAuditLogs = auditLogs.filter((log) => {
const isEx = isLogException(log);
if (auditTypeFilter === 'audits' && isEx) {
return false;
}
if (auditTypeFilter === 'exceptions' && !isEx) {
return false;
}
if (auditEntityFilter !== 'all' && (log.entityName || '').toLowerCase() !== auditEntityFilter.toLowerCase()) {
return false;
}
@@ -449,6 +468,7 @@ export const SettingsPage: React.FC = () => {
const entityId = String(log.entityId || '').toLowerCase();
const performedBy = (log.performedBy || '').toLowerCase();
const tenant = String(log.tenantId || '').toLowerCase();
const err = (log.error || '').toLowerCase();
const changes = Array.isArray(log.changes) ? log.changes.join(' ').toLowerCase() : '';
return (
id.includes(q) ||
@@ -457,6 +477,7 @@ export const SettingsPage: React.FC = () => {
entityId.includes(q) ||
performedBy.includes(q) ||
tenant.includes(q) ||
err.includes(q) ||
changes.includes(q)
);
});
@@ -468,6 +489,13 @@ export const SettingsPage: React.FC = () => {
handleSort: handleAuditSort,
} = useTableSort(filteredAuditLogs, 'timestamp', 'desc');
const totalAuditRecords = sortedAuditLogs.length;
const totalAuditPages = Math.max(1, Math.ceil(totalAuditRecords / auditPageSize));
const paginatedAuditLogs = React.useMemo(() => {
const start = (auditPage - 1) * auditPageSize;
return sortedAuditLogs.slice(start, start + auditPageSize);
}, [sortedAuditLogs, auditPage, auditPageSize]);
const userExportColumns: ExportColumn<User>[] = [
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
{ header: 'User Identifier', key: 'id' },
@@ -1532,7 +1560,10 @@ export const SettingsPage: React.FC = () => {
<input
type="text"
value={auditSearchTerm}
onChange={(e) => setAuditSearchTerm(e.target.value)}
onChange={(e) => {
setAuditSearchTerm(e.target.value);
setAuditPage(1);
}}
placeholder="Search audit trail by action, entity name, ID, performed by UUID..."
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
@@ -1543,7 +1574,10 @@ export const SettingsPage: React.FC = () => {
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Entity:</span>
<select
value={auditEntityFilter}
onChange={(e) => setAuditEntityFilter(e.target.value)}
onChange={(e) => {
setAuditEntityFilter(e.target.value);
setAuditPage(1);
}}
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
>
<option value="all">All Entities</option>
@@ -1568,7 +1602,10 @@ export const SettingsPage: React.FC = () => {
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Action:</span>
<select
value={auditActionFilter}
onChange={(e) => setAuditActionFilter(e.target.value)}
onChange={(e) => {
setAuditActionFilter(e.target.value);
setAuditPage(1);
}}
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
>
<option value="all">All Actions</option>
@@ -1580,13 +1617,31 @@ export const SettingsPage: React.FC = () => {
</select>
</div>
{(auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all') && (
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Type:</span>
<select
value={auditTypeFilter}
onChange={(e) => {
setAuditTypeFilter(e.target.value as 'all' | 'audits' | 'exceptions');
setAuditPage(1);
}}
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
>
<option value="all">All Types ({auditLogs.length})</option>
<option value="audits">Audits Only ({auditLogs.filter((l) => !l.error && !l.isError).length})</option>
<option value="exceptions">Exceptions Only ({auditLogs.filter((l) => Boolean(l.error || l.isError)).length})</option>
</select>
</div>
{(auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all' || auditTypeFilter !== 'all') && (
<button
type="button"
onClick={() => {
setAuditSearchTerm('');
setAuditEntityFilter('all');
setAuditActionFilter('all');
setAuditTypeFilter('all');
setAuditPage(1);
}}
className="px-2.5 py-1.5 text-xs text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
>
@@ -1624,12 +1679,18 @@ export const SettingsPage: React.FC = () => {
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">
Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<span>Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})</span>
{auditLogs.some((l) => Boolean(l.error || l.isError)) && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20">
<AlertTriangle className="w-3 h-3 text-rose-400" />
{auditLogs.filter((l) => Boolean(l.error || l.isError)).length} Exceptions
</span>
)}
</h3>
<HelpTooltip
title="Audit Trail"
content="Authoritative record of state changes and system activities."
content="Authoritative record of state changes, exceptions, and system activities."
size="sm"
placement="right"
/>
@@ -1671,13 +1732,14 @@ export const SettingsPage: React.FC = () => {
<tr>
<td colSpan={6} className="py-12 text-center text-slate-500">
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all' || auditTypeFilter !== 'all'
? 'No audit log entries match your filter criteria.'
: 'No audit records found.'}
</td>
</tr>
) : (
sortedAuditLogs.map((log) => {
paginatedAuditLogs.map((log) => {
const isException = Boolean(log.error || log.isError);
const badge = getActionBadge(log.action);
const isExpanded = !!expandedAuditLogIds[log.id];
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
@@ -1689,8 +1751,14 @@ export const SettingsPage: React.FC = () => {
<React.Fragment key={log.id}>
<tr
onClick={() => toggleExpandAuditLog(log.id)}
className={`hover:bg-slate-800/40 transition-colors cursor-pointer ${
isExpanded ? 'bg-slate-800/30' : ''
className={`transition-colors cursor-pointer border-l-4 ${
isException
? isExpanded
? 'border-l-rose-500 bg-rose-950/25 hover:bg-rose-950/35'
: 'border-l-rose-500 bg-rose-950/15 hover:bg-rose-950/25'
: isExpanded
? 'border-l-blue-500 bg-slate-800/40 hover:bg-slate-800/50'
: 'border-l-transparent hover:bg-slate-800/40'
}`}
>
<td className="py-3.5 px-3 text-center">
@@ -1705,7 +1773,13 @@ export const SettingsPage: React.FC = () => {
>
<ChevronRight
className={`w-3.5 h-3.5 transition-transform duration-200 ${
isExpanded ? 'rotate-90 text-blue-400' : 'text-slate-500'
isExpanded
? isException
? 'rotate-90 text-rose-400'
: 'rotate-90 text-blue-400'
: isException
? 'text-rose-400/80'
: 'text-slate-500'
}`}
/>
</button>
@@ -1714,12 +1788,33 @@ export const SettingsPage: React.FC = () => {
{formatDate(log.timestamp)}
</td>
<td className="py-3.5 px-4 font-mono font-bold text-white whitespace-nowrap">
#{log.id}
<div className="flex items-center gap-1.5">
<span>#{log.id}</span>
{isException && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-rose-950 text-rose-300 border border-rose-700/60 shadow-xs"
title="Exception captured"
>
<AlertTriangle className="w-2.5 h-2.5 text-rose-400 shrink-0" />
Exception
</span>
)}
</div>
</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
{badge.label}
</span>
{isException ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border bg-rose-500/10 text-rose-400 border-rose-500/30">
<AlertCircle className="w-3 h-3 text-rose-400 shrink-0" />
<span>{log.action || 'EXCEPTION'}</span>
<span className="ml-0.5 px-1 py-0.2 rounded bg-rose-500/20 text-[9px] font-bold text-rose-300 uppercase">
FAIL
</span>
</span>
) : (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
{badge.label}
</span>
)}
</td>
<td
className="py-3.5 px-4"
@@ -1749,18 +1844,34 @@ export const SettingsPage: React.FC = () => {
</div>
</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedAuditLog(log);
setIsAuditDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
>
<Eye className="w-3 h-3 text-blue-400" />
Inspect
</button>
{isException ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedExceptionLog(log);
setIsExceptionDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-rose-300 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-lg transition cursor-pointer"
title="Inspect exception details"
>
<AlertTriangle className="w-3 h-3 text-rose-400" />
Inspect Error
</button>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedAuditLog(log);
setIsAuditDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
>
<Eye className="w-3 h-3 text-blue-400" />
Inspect
</button>
)}
</td>
</tr>
@@ -1771,11 +1882,21 @@ export const SettingsPage: React.FC = () => {
<div className="rounded-xl border border-slate-800/90 bg-[#070a12]/90 p-4 space-y-3 shadow-inner">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80 pb-3 flex-wrap">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-blue-400 shrink-0" />
{isException ? (
<AlertOctagon className="w-4 h-4 text-rose-400 shrink-0" />
) : (
<Layers className="w-4 h-4 text-blue-400 shrink-0" />
)}
<h4 className="text-xs font-bold text-white uppercase tracking-wider">
Recorded Changes Content (Event #{log.id})
{isException
? `Captured Exception & Invocation Payload (Event #${log.id})`
: `Recorded Changes Content (Event #${log.id})`}
</h4>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-semibold shrink-0">
<span className={`text-[10px] font-mono px-2 py-0.5 rounded-full font-semibold shrink-0 ${
isException
? 'bg-rose-500/10 text-rose-400 border border-rose-500/20'
: 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
}`}>
{changesCount} {changesCount === 1 ? 'Entry' : 'Entries'}
</span>
</div>
@@ -1804,6 +1925,32 @@ export const SettingsPage: React.FC = () => {
</div>
</div>
{/* Exception Diagnostic Banner inside expanded shelf */}
{log.error && (
<div className="p-3 bg-rose-950/40 border border-rose-800/60 rounded-xl space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-rose-300 uppercase tracking-wider flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 text-rose-400" />
Captured Exception Diagnostic Trace
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(log.error || '');
success('Copied', 'Exception details copied to clipboard.');
}}
className="text-[10px] text-rose-300 hover:text-white bg-rose-900/50 hover:bg-rose-900 px-2 py-0.5 rounded border border-rose-700/60 transition cursor-pointer flex items-center gap-1"
>
<Copy className="w-3 h-3" /> Copy Details
</button>
</div>
<pre className="text-xs font-mono text-rose-200 whitespace-pre-wrap break-all bg-black/40 p-2.5 rounded-lg border border-rose-900/40 max-h-40 overflow-y-auto leading-relaxed">
{log.error}
</pre>
</div>
)}
{/* Changes List */}
{Array.isArray(log.changes) && log.changes.length > 0 ? (
<div className="space-y-2">
@@ -1823,7 +1970,7 @@ export const SettingsPage: React.FC = () => {
</div>
) : (
<div className="p-4 rounded-xl bg-slate-900/50 border border-slate-800/80 text-center text-slate-400 text-xs font-mono">
No itemized field changes content recorded for this event.
No itemized field changes or invocation arguments recorded for this event.
</div>
)}
</div>
@@ -1837,6 +1984,64 @@ export const SettingsPage: React.FC = () => {
</tbody>
</table>
</div>
{/* Pagination Controls */}
{totalAuditRecords > 0 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3 border-t border-slate-800/80 bg-slate-900/40 text-xs text-slate-400">
<div className="flex items-center gap-2">
<span>
Showing <strong className="text-white font-mono">{Math.min((auditPage - 1) * auditPageSize + 1, totalAuditRecords)}</strong> to{' '}
<strong className="text-white font-mono">{Math.min(auditPage * auditPageSize, totalAuditRecords)}</strong> of{' '}
<strong className="text-white font-mono">{totalAuditRecords}</strong> records
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-slate-400">Per page:</span>
<select
value={auditPageSize}
onChange={(e) => {
setAuditPageSize(Number(e.target.value));
setAuditPage(1);
}}
className="bg-slate-900 border border-slate-700/80 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
disabled={auditPage <= 1}
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
title="Previous page"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-2 font-mono text-slate-300">
{auditPage} / {totalAuditPages}
</span>
<button
type="button"
onClick={() => setAuditPage((p) => Math.min(totalAuditPages, p + 1))}
disabled={auditPage >= totalAuditPages}
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
title="Next page"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
</div>
)}
@@ -1990,7 +2195,195 @@ export const SettingsPage: React.FC = () => {
</Modal>
{/* ============================================================== */}
{/* Modal: Dispatch Invitation */}
{/* Modal: Exception Detail Inspection Modal */}
{/* ============================================================== */}
<Modal
isOpen={isExceptionDetailModalOpen}
onClose={() => {
setIsExceptionDetailModalOpen(false);
setSelectedExceptionLog(null);
setCopiedExceptionTrace(false);
}}
title={`Captured Exception: Event #${selectedExceptionLog?.id || ''}`}
subtitle="Runtime exception diagnostics and invocation telemetry"
maxWidth="2xl"
>
{selectedExceptionLog && (
<div className="space-y-4 text-xs min-w-0">
{/* Header Alert Box */}
<div className="p-4 bg-rose-950/30 border border-rose-500/30 rounded-xl space-y-3">
<div className="flex items-center justify-between border-b border-rose-900/50 pb-2.5 flex-wrap gap-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-rose-500/20 border border-rose-500/30 flex items-center justify-center text-rose-400 shrink-0">
<AlertTriangle className="w-4 h-4" />
</div>
<div>
<span className="text-white font-bold text-sm block">System Exception Captured</span>
<span className="text-[11px] text-rose-300/80 font-mono">
Action: <strong className="text-white">{selectedExceptionLog.action || 'Unknown'}</strong>
</span>
</div>
</div>
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/40">
<AlertCircle className="w-3.5 h-3.5 text-rose-400" />
FAILED EXECUTION
</span>
</div>
{/* Entity and Context Details */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity / Target:</span>
<span className="text-white font-semibold">
{selectedExceptionLog.entityName || 'General'}{' '}
{selectedExceptionLog.entityId ? `(#${selectedExceptionLog.entityId})` : ''}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Logged Timestamp:</span>
<span className="font-mono text-slate-300">{formatDate(selectedExceptionLog.timestamp)}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Triggered By:</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-slate-200 text-[11px] truncate" title={selectedExceptionLog.performedBy || 'System'}>
{getUserEmail(selectedExceptionLog.performedBy) !== '—'
? getUserEmail(selectedExceptionLog.performedBy)
: (selectedExceptionLog.performedBy || 'System')}
</span>
{selectedExceptionLog.performedBy && (
<button
type="button"
onClick={(e) => handleCopyUserId(selectedExceptionLog.performedBy, e)}
className="p-1 rounded hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer"
title={`Copy User ID: ${selectedExceptionLog.performedBy}`}
>
{copiedUserId === selectedExceptionLog.performedBy ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5 hover:text-blue-400" />
)}
</button>
)}
</div>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Tenant Scope:</span>
<span className="font-mono text-slate-300">
{selectedExceptionLog.tenantId ? `Tenant #${selectedExceptionLog.tenantId}` : '—'}
</span>
</div>
</div>
</div>
{/* Error Message & Stack Trace Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-300 font-bold uppercase text-[11px] flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-rose-400" />
Exception Diagnostic Message &amp; Trace
</span>
<button
type="button"
onClick={() => {
const textToCopy = selectedExceptionLog.error || 'No error details recorded.';
navigator.clipboard.writeText(textToCopy);
setCopiedExceptionTrace(true);
success('Exception Copied', 'Exception trace copied to clipboard.');
setTimeout(() => setCopiedExceptionTrace(false), 2000);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-700 text-[11px] transition cursor-pointer"
>
{copiedExceptionTrace ? (
<>
<Check className="w-3 h-3 text-emerald-400" />
<span>Copied</span>
</>
) : (
<>
<Copy className="w-3 h-3 text-blue-400" />
<span>Copy Trace</span>
</>
)}
</button>
</div>
<div className="bg-[#050810] border border-rose-900/50 rounded-xl p-4 overflow-hidden shadow-inner">
<pre className="font-mono text-rose-400 text-xs whitespace-pre-wrap break-all max-h-72 overflow-y-auto leading-relaxed selection:bg-rose-900 selection:text-white">
{selectedExceptionLog.error || 'No stack trace or error message captured.'}
</pre>
</div>
</div>
{/* Invocation Arguments / Parameters */}
<div className="space-y-2">
<span className="text-slate-300 font-bold uppercase text-[11px] flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-blue-400" />
Invocation Arguments &amp; Parameters
</span>
{Array.isArray(selectedExceptionLog.changes) && selectedExceptionLog.changes.length > 0 ? (
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
{selectedExceptionLog.changes.map((param, idx) => (
<div
key={idx}
className="flex items-start gap-2.5 p-2.5 rounded-lg bg-slate-900/90 border border-slate-800 text-xs font-mono"
>
<span className="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/20 text-[10px] font-bold shrink-0 mt-0.5">
#{idx + 1}
</span>
<span className="break-all text-slate-300 flex-1 leading-relaxed">
{param}
</span>
</div>
))}
</div>
) : (
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-center text-slate-500 font-mono text-[11px]">
No parameters or arguments recorded for this invocation.
</div>
)}
</div>
{/* Raw JSON Data Viewer */}
<div className="border-t border-slate-800/80 pt-2.5 space-y-1.5 min-w-0">
<div className="flex items-center justify-between">
<span className="text-slate-400 font-semibold text-[11px] uppercase">
Raw Exception Record (JSON):
</span>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(selectedExceptionLog, null, 2));
success('Copied', 'JSON payload copied to clipboard.');
}}
className="inline-flex items-center gap-1 text-[10px] font-medium text-slate-400 hover:text-white bg-slate-800 px-2 py-1 rounded transition cursor-pointer"
>
<Copy className="w-3 h-3" />
<span>Copy JSON</span>
</button>
</div>
<pre className="p-3 rounded-lg bg-[#070a12] border border-slate-800/80 text-rose-300/90 font-mono text-[10.5px] overflow-x-auto whitespace-pre-wrap break-all max-h-40 min-w-0">
{JSON.stringify(selectedExceptionLog, null, 2)}
</pre>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-end pt-3 border-t border-slate-800 gap-2">
<button
type="button"
onClick={() => {
setIsExceptionDetailModalOpen(false);
setSelectedExceptionLog(null);
}}
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer"
>
Close
</button>
</div>
</div>
)}
</Modal>
{/* ============================================================== */}
<Modal
isOpen={isSendInviteModalOpen}
@@ -346,6 +346,8 @@ export interface AuditLog {
changes?: string[] | null;
performedBy: string;
timestamp: string;
error?: string | null;
isError?: boolean;
}
export interface UserFilterRequest {
@@ -17,5 +17,7 @@ public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
builder.Property(f => f.Changes).IsRequired(false);
builder.Property(f => f.PerformedBy).IsRequired();
builder.Property(f => f.Timestamp).IsRequired().HasDefaultValueSql("now()");
builder.Property(f => f.Error).IsRequired(false);
builder.Ignore(f => f.IsError);
}
}
}
@@ -65,7 +65,8 @@ public static class Mappers
EntityId = entity.EntityId,
EntityName = entity.EntityName,
PerformedBy = entity.PerformedBy,
Timestamp = entity.Timestamp
Timestamp = entity.Timestamp,
Error = entity.Error
};
public static Award Map(this Entities.Award entity) => new()
@@ -398,4 +399,4 @@ public static class Mappers
Role = entity.Role,
IdNumber = entity.IdNumber
};
}
}
+5 -1
View File
@@ -17,4 +17,8 @@ public class AuditLog
public Guid PerformedBy { get; set; }
public DateTime Timestamp { get; set; }
}
public string? Error { get; set; }
public bool IsError => !string.IsNullOrWhiteSpace(Error);
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PostFundManagement.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class AddedErrorToAuditLog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Error",
table: "AuditLogs",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Error",
table: "AuditLogs");
}
}
}
@@ -125,6 +125,9 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Error")
.HasColumnType("text");
b.Property<Guid>("PerformedBy")
.HasColumnType("uuid");
@@ -537,6 +537,8 @@ export interface AuditLog {
timestamp?: string;
performedBy?: string;
details?: string;
error?: string | null;
isError?: boolean;
[key: string]: any;
}
+312
View File
@@ -0,0 +1,312 @@
using System.Reflection;
using System.Security.Claims;
using Castle.DynamicProxy;
using FluentResults;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using PostFundManagement.Application.Interceptors;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
using Entities = PostFundManagement.Domain.Entities;
using Models = PostFundManagement.Domain.Models;
namespace PostFundManagement.Tests;
public class AuditLogTests
{
[Fact]
public void AuditLog_WhenErrorNull_IsErrorIsFalse()
{
var model = new Models.AuditLog
{
Id = 1,
TenantId = 1,
Action = "Test",
Error = null
};
Assert.False(model.IsError);
}
[Fact]
public void AuditLog_WhenErrorPopulated_IsErrorIsTrue()
{
var model = new Models.AuditLog
{
Id = 2,
TenantId = 1,
Action = "Test",
Error = "[ValidationError] Something went wrong"
};
Assert.True(model.IsError);
Assert.Equal("[ValidationError] Something went wrong", model.Error);
}
[Fact]
public void AuditLog_Map_CorrectlyMapsErrorField()
{
var entity = new Entities.AuditLog
{
Id = 10,
TenantId = 3,
Action = "RegisterAward",
Changes = ["amount: 50000"],
EntityId = "AW-100",
EntityName = "Award",
PerformedBy = Guid.NewGuid(),
Timestamp = DateTime.UtcNow,
Error = "[Exception] InvalidOperationException: Award already exists"
};
var mapped = entity.Map();
Assert.Equal(entity.Id, mapped.Id);
Assert.Equal(entity.TenantId, mapped.TenantId);
Assert.Equal(entity.Action, mapped.Action);
Assert.Equal(entity.Changes, mapped.Changes);
Assert.Equal(entity.EntityId, mapped.EntityId);
Assert.Equal(entity.EntityName, mapped.EntityName);
Assert.Equal(entity.PerformedBy, mapped.PerformedBy);
Assert.Equal(entity.Timestamp, mapped.Timestamp);
Assert.Equal(entity.Error, mapped.Error);
Assert.True(mapped.IsError);
}
public interface ITestService
{
[Audit]
Task<Result> DoSuccessfulActionAsync(string param);
[Audit]
Task<Result> DoFailedActionAsync(string param);
[Audit]
Task<Result> ThrowExceptionAsync(string param);
}
public class TestService : ITestService
{
public Task<Result> DoSuccessfulActionAsync(string param) =>
Task.FromResult(Result.Ok());
public Task<Result> DoFailedActionAsync(string param) =>
Task.FromResult(Result.Fail(new Error("Database connection timed out")));
public Task<Result> ThrowExceptionAsync(string param) =>
throw new InvalidOperationException("Fatal system error occurred");
}
private sealed class DummyTenantContextService : ITenantContextService
{
public string? TenantName => "TestTenant";
public long TenantId { get; }
public bool ByPassTenantQueryFilter { get; set; }
public bool IsAdmin { get; }
public bool HasTenantFilter => !IsAdmin;
public DummyTenantContextService(long tenantId, bool isAdmin)
{
TenantId = tenantId;
IsAdmin = isAdmin;
ByPassTenantQueryFilter = isAdmin;
}
}
private sealed class TestDbContextFactory(DbContextOptions<ApplicationDbContext> options, ITenantContextService tenantService) : IDbContextFactory<ApplicationDbContext>
{
public ApplicationDbContext CreateDbContext() => new(options, tenantService);
public Task<ApplicationDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(new ApplicationDbContext(options, tenantService));
}
[Fact]
public async Task AuditInterceptor_WhenActionFails_LogsErrorDetails()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false);
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
var httpContext = new DefaultHttpContext();
var userId = Guid.NewGuid();
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim("tenant_id", "5")
};
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var proxyGenerator = new ProxyGenerator();
var interceptor = new AuditInterceptor(auditService, httpContextAccessor);
var target = new TestService();
var proxy = proxyGenerator.CreateInterfaceProxyWithTarget<ITestService>(target, interceptor);
// Act
var result = await proxy.DoFailedActionAsync("test_payload");
// Assert
Assert.True(result.IsFailed);
await using var verifyContext = new ApplicationDbContext(options, tenantService);
var logs = await verifyContext.AuditLogs.ToListAsync();
Assert.Single(logs);
var log = logs[0];
Assert.Equal("DoFailedAction", log.Action);
Assert.Equal(5, log.TenantId);
Assert.Equal(userId, log.PerformedBy);
Assert.NotNull(log.Error);
Assert.Contains("Database connection timed out", log.Error);
}
[Fact]
public async Task AuditInterceptor_WhenActionThrowsException_LogsExceptionAndRethrows()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false);
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
var httpContext = new DefaultHttpContext();
var userId = Guid.NewGuid();
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim("tenant_id", "5")
};
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var proxyGenerator = new ProxyGenerator();
var interceptor = new AuditInterceptor(auditService, httpContextAccessor);
var target = new TestService();
var proxy = proxyGenerator.CreateInterfaceProxyWithTarget<ITestService>(target, interceptor);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => proxy.ThrowExceptionAsync("test_payload"));
await using var verifyContext = new ApplicationDbContext(options, tenantService);
var logs = await verifyContext.AuditLogs.ToListAsync();
Assert.Single(logs);
var log = logs[0];
Assert.Equal("ThrowException", log.Action);
Assert.Equal(5, log.TenantId);
Assert.Equal(userId, log.PerformedBy);
Assert.NotNull(log.Error);
Assert.Contains("InvalidOperationException", log.Error);
Assert.Contains("Fatal system error occurred", log.Error);
}
[Fact]
public async Task AuditInterceptor_WhenActionSucceeds_LogsWithoutError()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false);
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
var httpContext = new DefaultHttpContext();
var userId = Guid.NewGuid();
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim("tenant_id", "5")
};
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var proxyGenerator = new ProxyGenerator();
var interceptor = new AuditInterceptor(auditService, httpContextAccessor);
var target = new TestService();
var proxy = proxyGenerator.CreateInterfaceProxyWithTarget<ITestService>(target, interceptor);
// Act
var result = await proxy.DoSuccessfulActionAsync("test_payload");
// Assert
Assert.True(result.IsSuccess);
await using var verifyContext = new ApplicationDbContext(options, tenantService);
var logs = await verifyContext.AuditLogs.ToListAsync();
Assert.Single(logs);
var log = logs[0];
Assert.Equal("DoSuccessfulAction", log.Action);
Assert.Equal(5, log.TenantId);
Assert.Equal(userId, log.PerformedBy);
Assert.Null(log.Error);
}
[Fact]
public async Task AuditService_GetAuditLogsAsync_NonAdmin_FiltersByTenant()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 2, isAdmin: false);
await using (var dbContext = new ApplicationDbContext(options, tenantService))
{
dbContext.AuditLogs.AddRange(
new Entities.AuditLog { Id = 1, TenantId = 1, Action = "A1", EntityId = "1", EntityName = "E1", PerformedBy = Guid.NewGuid() },
new Entities.AuditLog { Id = 2, TenantId = 2, Action = "A2", EntityId = "2", EntityName = "E2", PerformedBy = Guid.NewGuid() },
new Entities.AuditLog { Id = 3, TenantId = 3, Action = "A3", EntityId = "3", EntityName = "E3", PerformedBy = Guid.NewGuid() }
);
await dbContext.SaveChangesAsync();
}
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
// Act
var result = await auditService.GetAuditLogsAsync(Pagination.Create(1, 10));
// Assert
Assert.True(result.IsSuccess);
Assert.Single(result.Value);
Assert.Equal(2, result.Value[0].TenantId);
}
[Fact]
public async Task AuditService_GetAuditLogsAsync_Tenant1Admin_SeesAllTenantsLogs()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 1, isAdmin: true);
await using (var dbContext = new ApplicationDbContext(options, tenantService))
{
dbContext.AuditLogs.AddRange(
new Entities.AuditLog { Id = 1, TenantId = 1, Action = "A1", EntityId = "1", EntityName = "E1", PerformedBy = Guid.NewGuid() },
new Entities.AuditLog { Id = 2, TenantId = 2, Action = "A2", EntityId = "2", EntityName = "E2", PerformedBy = Guid.NewGuid() },
new Entities.AuditLog { Id = 3, TenantId = 3, Action = "A3", EntityId = "3", EntityName = "E3", PerformedBy = Guid.NewGuid() }
);
await dbContext.SaveChangesAsync();
}
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
// Act
var result = await auditService.GetAuditLogsAsync(Pagination.Create(1, 10));
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(3, result.Value.Length);
}
}