Merge pull request 'Implemented audit logging' (#150) from finishline into main
Reviewed-on: #150
This commit was merged in pull request #150.
This commit is contained in:
@@ -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;
|
using PostFundManagement.Domain.Attributes;
|
||||||
|
|
||||||
namespace PostFundManagement.Application.Interceptors;
|
namespace PostFundManagement.Application.Interceptors;
|
||||||
|
|
||||||
public sealed class AuditInterceptor(AuditService auditService, IHttpContextAccessor httpContextAccessor) : IAsyncInterceptor
|
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) =>
|
public void InterceptAsynchronous(IInvocation invocation) =>
|
||||||
invocation.ReturnValue = InternalInterceptAsynchronous(invocation);
|
invocation.ReturnValue = InternalInterceptAsynchronous(invocation);
|
||||||
@@ -13,49 +41,177 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
|||||||
public void InterceptAsynchronous<TResult>(IInvocation invocation) =>
|
public void InterceptAsynchronous<TResult>(IInvocation invocation) =>
|
||||||
invocation.ReturnValue = InternalInterceptAsynchronous<TResult>(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 method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||||
|
|
||||||
if (auditAttr == null) return resultObj;
|
if (auditAttr == null)
|
||||||
|
|
||||||
if (resultObj is IResultBase result && result.IsSuccess)
|
|
||||||
{
|
{
|
||||||
try
|
invocation.Proceed();
|
||||||
{
|
var t = (Task)invocation.ReturnValue;
|
||||||
var action = ResolveActionName(method.Name);
|
await t.ConfigureAwait(false);
|
||||||
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
|
return;
|
||||||
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
|
}
|
||||||
var changes = BuildPayloadSummary(invocation.Method.GetParameters(), invocation.Arguments);
|
|
||||||
var performedBy = ResolveUserId();
|
|
||||||
|
|
||||||
|
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(
|
await auditService.LogActionAsync(
|
||||||
entityId: entityId,
|
entityId: entityId,
|
||||||
entityName: entityName,
|
entityName: entityName,
|
||||||
action: action,
|
action: action,
|
||||||
changes: changes,
|
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) =>
|
private static string ResolveActionName(string methodName) =>
|
||||||
@@ -73,7 +229,7 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
|||||||
{
|
{
|
||||||
var genericDef = returnType.GetGenericTypeDefinition();
|
var genericDef = returnType.GetGenericTypeDefinition();
|
||||||
|
|
||||||
if (genericDef == typeof(Task<>) || genericDef == typeof(Task<>))
|
if (genericDef == typeof(Task<>))
|
||||||
innerType = returnType.GetGenericArguments()[0];
|
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;
|
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.Abstractions;
|
||||||
using PostFundManagement.Domain.Errors;
|
using PostFundManagement.Domain.Errors;
|
||||||
using PostFundManagement.Domain.Extensions;
|
using PostFundManagement.Domain.Extensions;
|
||||||
@@ -10,27 +10,22 @@ namespace PostFundManagement.Application.Services.Shared;
|
|||||||
public sealed class AuditService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContextService) : IService
|
public sealed class AuditService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContextService) : IService
|
||||||
{
|
{
|
||||||
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(CancellationToken cancellationToken = default) =>
|
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
|
await GetAuditLogsAsync(new Pagination { Page = 1, PageSize = 500, Index = 1 }, cancellationToken);
|
||||||
{
|
|
||||||
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());
|
|
||||||
}));
|
|
||||||
|
|
||||||
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
|
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
|
||||||
{
|
{
|
||||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
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)
|
.OrderByDescending(o => o.Id)
|
||||||
.Skip((pagination.Page - 1) * pagination.PageSize)
|
.Skip((pagination.Page - 1) * pagination.PageSize)
|
||||||
.Take(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());
|
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 Result.Try((Func<Task<Result>>)(async () =>
|
||||||
{
|
{
|
||||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
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
|
context.Add(new Domain.Entities.AuditLog
|
||||||
{
|
{
|
||||||
EntityId = entityId,
|
EntityId = entityId,
|
||||||
@@ -52,7 +59,8 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
|
|||||||
EntityName = entityName,
|
EntityName = entityName,
|
||||||
PerformedBy = performedBy,
|
PerformedBy = performedBy,
|
||||||
Timestamp = DateTime.UtcNow,
|
Timestamp = DateTime.UtcNow,
|
||||||
TenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : null,
|
TenantId = resolvedTenantId,
|
||||||
|
Error = error,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New);
|
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New);
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
Award as AwardIcon,
|
Award as AwardIcon,
|
||||||
FileText,
|
FileText,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
AlertOctagon,
|
||||||
Layers,
|
Layers,
|
||||||
Activity,
|
Activity,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -370,8 +372,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [auditSearchTerm, setAuditSearchTerm] = useState('');
|
const [auditSearchTerm, setAuditSearchTerm] = useState('');
|
||||||
const [auditEntityFilter, setAuditEntityFilter] = useState<string>('all');
|
const [auditEntityFilter, setAuditEntityFilter] = useState<string>('all');
|
||||||
const [auditActionFilter, setAuditActionFilter] = 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 [selectedAuditLog, setSelectedAuditLog] = useState<AuditLog | null>(null);
|
||||||
const [isAuditDetailModalOpen, setIsAuditDetailModalOpen] = useState(false);
|
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 [copiedAuditPayload, setCopiedAuditPayload] = useState(false);
|
||||||
const [expandedAuditLogIds, setExpandedAuditLogIds] = useState<Record<string | number, boolean>>({});
|
const [expandedAuditLogIds, setExpandedAuditLogIds] = useState<Record<string | number, boolean>>({});
|
||||||
const [copiedUserId, setCopiedUserId] = useState<string | number | null>(null);
|
const [copiedUserId, setCopiedUserId] = useState<string | number | null>(null);
|
||||||
@@ -402,11 +410,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isFetching: isFetchingAuditLogs,
|
isFetching: isFetchingAuditLogs,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['audit-logs'],
|
queryKey: ['audit-logs'],
|
||||||
queryFn: () => getAuditLogs(1, 100),
|
queryFn: () => getAuditLogs(1, 500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isLogException = (log: AuditLog) => Boolean(log.error || log.isError);
|
||||||
|
|
||||||
const auditExportColumns: ExportColumn<AuditLog>[] = [
|
const auditExportColumns: ExportColumn<AuditLog>[] = [
|
||||||
{ header: 'Event ID', key: 'id' },
|
{ 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: 'Timestamp', key: 'timestamp', format: (val) => formatDate(val) },
|
||||||
{ header: 'Action', key: 'action', format: (val) => val || '—' },
|
{ header: 'Action', key: 'action', format: (val) => val || '—' },
|
||||||
{ header: 'Entity Name', key: 'entityName', 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: 'Tenant ID', key: 'tenantId', format: (val) => (val ? `Tenant #${val}` : '—') },
|
||||||
{
|
{
|
||||||
header: 'Changes',
|
header: 'Changes / Arguments',
|
||||||
key: 'changes',
|
key: 'changes',
|
||||||
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
|
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
|
||||||
},
|
},
|
||||||
|
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const uniqueAuditEntities = Array.from(
|
const uniqueAuditEntities = Array.from(
|
||||||
@@ -435,6 +447,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredAuditLogs = auditLogs.filter((log) => {
|
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()) {
|
if (auditEntityFilter !== 'all' && (log.entityName || '').toLowerCase() !== auditEntityFilter.toLowerCase()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -449,6 +468,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const entityId = String(log.entityId || '').toLowerCase();
|
const entityId = String(log.entityId || '').toLowerCase();
|
||||||
const performedBy = (log.performedBy || '').toLowerCase();
|
const performedBy = (log.performedBy || '').toLowerCase();
|
||||||
const tenant = String(log.tenantId || '').toLowerCase();
|
const tenant = String(log.tenantId || '').toLowerCase();
|
||||||
|
const err = (log.error || '').toLowerCase();
|
||||||
const changes = Array.isArray(log.changes) ? log.changes.join(' ').toLowerCase() : '';
|
const changes = Array.isArray(log.changes) ? log.changes.join(' ').toLowerCase() : '';
|
||||||
return (
|
return (
|
||||||
id.includes(q) ||
|
id.includes(q) ||
|
||||||
@@ -457,6 +477,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
entityId.includes(q) ||
|
entityId.includes(q) ||
|
||||||
performedBy.includes(q) ||
|
performedBy.includes(q) ||
|
||||||
tenant.includes(q) ||
|
tenant.includes(q) ||
|
||||||
|
err.includes(q) ||
|
||||||
changes.includes(q)
|
changes.includes(q)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -468,6 +489,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
handleSort: handleAuditSort,
|
handleSort: handleAuditSort,
|
||||||
} = useTableSort(filteredAuditLogs, 'timestamp', 'desc');
|
} = 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>[] = [
|
const userExportColumns: ExportColumn<User>[] = [
|
||||||
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
||||||
{ header: 'User Identifier', key: 'id' },
|
{ header: 'User Identifier', key: 'id' },
|
||||||
@@ -1532,7 +1560,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={auditSearchTerm}
|
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..."
|
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"
|
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>
|
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Entity:</span>
|
||||||
<select
|
<select
|
||||||
value={auditEntityFilter}
|
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"
|
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>
|
<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>
|
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Action:</span>
|
||||||
<select
|
<select
|
||||||
value={auditActionFilter}
|
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"
|
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>
|
<option value="all">All Actions</option>
|
||||||
@@ -1580,13 +1617,31 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAuditSearchTerm('');
|
setAuditSearchTerm('');
|
||||||
setAuditEntityFilter('all');
|
setAuditEntityFilter('all');
|
||||||
setAuditActionFilter('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"
|
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="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<History className="w-4 h-4 text-blue-400" />
|
<History className="w-4 h-4 text-blue-400" />
|
||||||
<h3 className="text-sm font-semibold text-white">
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})
|
<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>
|
</h3>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Audit Trail"
|
title="Audit Trail"
|
||||||
content="Authoritative record of state changes and system activities."
|
content="Authoritative record of state changes, exceptions, and system activities."
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="right"
|
placement="right"
|
||||||
/>
|
/>
|
||||||
@@ -1671,13 +1732,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-12 text-center text-slate-500">
|
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||||
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
<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 log entries match your filter criteria.'
|
||||||
: 'No audit records found.'}
|
: 'No audit records found.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
sortedAuditLogs.map((log) => {
|
paginatedAuditLogs.map((log) => {
|
||||||
|
const isException = Boolean(log.error || log.isError);
|
||||||
const badge = getActionBadge(log.action);
|
const badge = getActionBadge(log.action);
|
||||||
const isExpanded = !!expandedAuditLogIds[log.id];
|
const isExpanded = !!expandedAuditLogIds[log.id];
|
||||||
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
|
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
|
||||||
@@ -1689,8 +1751,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<React.Fragment key={log.id}>
|
<React.Fragment key={log.id}>
|
||||||
<tr
|
<tr
|
||||||
onClick={() => toggleExpandAuditLog(log.id)}
|
onClick={() => toggleExpandAuditLog(log.id)}
|
||||||
className={`hover:bg-slate-800/40 transition-colors cursor-pointer ${
|
className={`transition-colors cursor-pointer border-l-4 ${
|
||||||
isExpanded ? 'bg-slate-800/30' : ''
|
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">
|
<td className="py-3.5 px-3 text-center">
|
||||||
@@ -1705,7 +1773,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${
|
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>
|
</button>
|
||||||
@@ -1714,12 +1788,33 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{formatDate(log.timestamp)}
|
{formatDate(log.timestamp)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 font-mono font-bold text-white whitespace-nowrap">
|
<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>
|
||||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
<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}`}>
|
{isException ? (
|
||||||
{badge.label}
|
<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">
|
||||||
</span>
|
<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>
|
||||||
<td
|
<td
|
||||||
className="py-3.5 px-4"
|
className="py-3.5 px-4"
|
||||||
@@ -1749,18 +1844,34 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
||||||
<button
|
{isException ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
setSelectedAuditLog(log);
|
e.stopPropagation();
|
||||||
setIsAuditDetailModalOpen(true);
|
setSelectedExceptionLog(log);
|
||||||
}}
|
setIsExceptionDetailModalOpen(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"
|
}}
|
||||||
>
|
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"
|
||||||
<Eye className="w-3 h-3 text-blue-400" />
|
title="Inspect exception details"
|
||||||
Inspect
|
>
|
||||||
</button>
|
<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>
|
</td>
|
||||||
</tr>
|
</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="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 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">
|
<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">
|
<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>
|
</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'}
|
{changesCount} {changesCount === 1 ? 'Entry' : 'Entries'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1804,6 +1925,32 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Changes List */}
|
||||||
{Array.isArray(log.changes) && log.changes.length > 0 ? (
|
{Array.isArray(log.changes) && log.changes.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1823,7 +1970,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1837,6 +1984,64 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1990,7 +2195,195 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</Modal>
|
</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 & 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 & 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
|
<Modal
|
||||||
isOpen={isSendInviteModalOpen}
|
isOpen={isSendInviteModalOpen}
|
||||||
|
|||||||
@@ -346,6 +346,8 @@ export interface AuditLog {
|
|||||||
changes?: string[] | null;
|
changes?: string[] | null;
|
||||||
performedBy: string;
|
performedBy: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
error?: string | null;
|
||||||
|
isError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserFilterRequest {
|
export interface UserFilterRequest {
|
||||||
|
|||||||
@@ -17,5 +17,7 @@ public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
|
|||||||
builder.Property(f => f.Changes).IsRequired(false);
|
builder.Property(f => f.Changes).IsRequired(false);
|
||||||
builder.Property(f => f.PerformedBy).IsRequired();
|
builder.Property(f => f.PerformedBy).IsRequired();
|
||||||
builder.Property(f => f.Timestamp).IsRequired().HasDefaultValueSql("now()");
|
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,
|
EntityId = entity.EntityId,
|
||||||
EntityName = entity.EntityName,
|
EntityName = entity.EntityName,
|
||||||
PerformedBy = entity.PerformedBy,
|
PerformedBy = entity.PerformedBy,
|
||||||
Timestamp = entity.Timestamp
|
Timestamp = entity.Timestamp,
|
||||||
|
Error = entity.Error
|
||||||
};
|
};
|
||||||
|
|
||||||
public static Award Map(this Entities.Award entity) => new()
|
public static Award Map(this Entities.Award entity) => new()
|
||||||
@@ -398,4 +399,4 @@ public static class Mappers
|
|||||||
Role = entity.Role,
|
Role = entity.Role,
|
||||||
IdNumber = entity.IdNumber
|
IdNumber = entity.IdNumber
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,8 @@ public class AuditLog
|
|||||||
public Guid PerformedBy { get; set; }
|
public Guid PerformedBy { get; set; }
|
||||||
|
|
||||||
public DateTime Timestamp { get; set; }
|
public DateTime Timestamp { get; set; }
|
||||||
}
|
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
public bool IsError => !string.IsNullOrWhiteSpace(Error);
|
||||||
|
}
|
||||||
|
|||||||
+2476
File diff suppressed because it is too large
Load Diff
+28
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -125,6 +125,9 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
|||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Error")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<Guid>("PerformedBy")
|
b.Property<Guid>("PerformedBy")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
|||||||
@@ -537,6 +537,8 @@ export interface AuditLog {
|
|||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
performedBy?: string;
|
performedBy?: string;
|
||||||
details?: string;
|
details?: string;
|
||||||
|
error?: string | null;
|
||||||
|
isError?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user