Audit service rework (#19346)

* Introduce new AuditEntryService

- Moved logic related to the IAuditEntryRepository from the AuditService to the new service
- Introduced new Async methods
  - Using ids (for easier transition from the previous Write method)
  - Using keys
- Moved and updated integration tests related to the audit entries to a new test class `AuditEntryServiceTests`
- Added unit tests class `AuditEntryServiceTests` and added a few unit tests
- Added migration to add columns for `performingUserKey` and `affectedUserKey` and convert existing user ids
- Adjusted usages of the old AuditService.Write method to use the new one (mostly notification handlers)

* Audit service rework

- Added new async and paged methods
- Marked (now) redundant methods as obsolete
- Updated all of the usages to use the non-obsolete methods
- Added unit tests class `AuditServiceTests` and some unit tests
- Updated existing integration test

* Apply suggestions from code review

* Small improvement

* Update src/Umbraco.Core/Services/AuditService.cs

* Some minor adjustments following the merge

* Delete unnecessary file

* Small cleanup on the tests
This commit is contained in:
Laura Neto
2025-07-01 09:12:37 +02:00
committed by GitHub
parent ceb745a7bd
commit 447cb881bd
13 changed files with 836 additions and 323 deletions

View File

@@ -1,8 +1,4 @@
using System.Globalization;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using System.Globalization;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Notifications;

View File

@@ -1,31 +1,53 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.Events;
public class RelateOnCopyNotificationHandler : INotificationHandler<ContentCopiedNotification>
public class RelateOnCopyNotificationHandler :
INotificationHandler<ContentCopiedNotification>,
INotificationAsyncHandler<ContentCopiedNotification>
{
private readonly IAuditService _auditService;
private readonly IUserIdKeyResolver _userIdKeyResolver;
private readonly IRelationService _relationService;
public RelateOnCopyNotificationHandler(IRelationService relationService, IAuditService auditService)
public RelateOnCopyNotificationHandler(
IRelationService relationService,
IAuditService auditService,
IUserIdKeyResolver userIdKeyResolver)
{
_relationService = relationService;
_auditService = auditService;
_userIdKeyResolver = userIdKeyResolver;
}
public void Handle(ContentCopiedNotification notification)
[Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in V19.")]
public RelateOnCopyNotificationHandler(
IRelationService relationService,
IAuditService auditService)
: this(
relationService,
auditService,
StaticServiceProvider.Instance.GetRequiredService<IUserIdKeyResolver>())
{
}
/// <inheritdoc />
public async Task HandleAsync(ContentCopiedNotification notification, CancellationToken cancellationToken)
{
if (notification.RelateToOriginal == false)
{
return;
}
IRelationType? relationType = _relationService.GetRelationTypeByAlias(Constants.Conventions.RelationTypes.RelateDocumentOnCopyAlias);
IRelationType? relationType =
_relationService.GetRelationTypeByAlias(Constants.Conventions.RelationTypes.RelateDocumentOnCopyAlias);
if (relationType == null)
{
@@ -43,11 +65,16 @@ public class RelateOnCopyNotificationHandler : INotificationHandler<ContentCopie
var relation = new Relation(notification.Original.Id, notification.Copy.Id, relationType);
_relationService.Save(relation);
_auditService.Add(
Guid writerKey = await _userIdKeyResolver.GetAsync(notification.Copy.WriterId);
await _auditService.AddAsync(
AuditType.Copy,
notification.Copy.WriterId,
writerKey,
notification.Copy.Id,
UmbracoObjectTypes.Document.GetName() ?? string.Empty,
$"Copied content with Id: '{notification.Copy.Id}' related to original content with Id: '{notification.Original.Id}'");
}
[Obsolete("Use the INotificationAsyncHandler.HandleAsync implementation instead. Scheduled for removal in V19.")]
public void Handle(ContentCopiedNotification notification) =>
HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult();
}

View File

@@ -7,15 +7,22 @@ using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services.Implement;
/// <summary>
/// Audit service for logging and retrieving audit entries.
/// </summary>
public sealed class AuditService : RepositoryService, IAuditService
{
private readonly IUserService _userService;
private readonly IAuditRepository _auditRepository;
private readonly IEntityService _entityService;
private readonly IUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="AuditService" /> class.
/// </summary>
public AuditService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
@@ -30,7 +37,10 @@ public sealed class AuditService : RepositoryService, IAuditService
_entityService = entityService;
}
[Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 19.")]
/// <summary>
/// Initializes a new instance of the <see cref="AuditService" /> class.
/// </summary>
[Obsolete("Use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
public AuditService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
@@ -49,164 +59,79 @@ public sealed class AuditService : RepositoryService, IAuditService
{
}
public void Add(AuditType type, int userId, int objectId, string? entityType, string comment, string? parameters = null)
/// <inheritdoc />
public async Task<Attempt<AuditLogOperationStatus>> AddAsync(
AuditType type,
Guid userKey,
int objectId,
string? entityType,
string? comment = null,
string? parameters = null)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
var userId = (await _userService.GetAsync(userKey))?.Id;
if (userId is null)
{
_auditRepository.Save(new AuditItem(objectId, type, userId, entityType, comment, parameters));
scope.Complete();
}
return Attempt.Fail(AuditLogOperationStatus.UserNotFound);
}
public IEnumerable<IAuditItem> GetLogs(int objectId)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
IEnumerable<IAuditItem> result = _auditRepository.Get(Query<IAuditItem>().Where(x => x.Id == objectId));
scope.Complete();
return result;
}
return AddInner(type, userId.Value, objectId, entityType, comment, parameters);
}
public IEnumerable<IAuditItem> GetUserLogs(int userId, AuditType type, DateTime? sinceDate = null)
/// <inheritdoc />
[Obsolete("Use AddAsync() instead. Scheduled for removal in Umbraco 19.")]
public void Add(
AuditType type,
int userId,
int objectId,
string? entityType,
string comment,
string? parameters = null) =>
AddInner(type, userId, objectId, entityType, comment, parameters);
/// <inheritdoc />
public Task<PagedModel<IAuditItem>> GetItemsAsync(
int skip,
int take,
Direction orderDirection = Direction.Descending,
DateTimeOffset? sinceDate = null,
AuditType[]? auditTypeFilter = null)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
IEnumerable<IAuditItem> result = sinceDate.HasValue == false
? _auditRepository.Get(type, Query<IAuditItem>().Where(x => x.UserId == userId))
: _auditRepository.Get(
type,
Query<IAuditItem>().Where(x => x.UserId == userId && x.CreateDate >= sinceDate.Value));
scope.Complete();
return result;
}
ArgumentOutOfRangeException.ThrowIfNegative(skip);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(take);
PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageIndex, out var pageSize);
IQuery<IAuditItem>? customFilter = sinceDate.HasValue
? Query<IAuditItem>().Where(x => x.CreateDate >= sinceDate)
: null;
PagedModel<IAuditItem> result = GetItemsInner(
null,
pageIndex,
pageSize,
orderDirection,
auditTypeFilter,
customFilter);
return Task.FromResult(result);
}
/// <inheritdoc />
[Obsolete("Use GetItemsAsync() instead. Scheduled for removal in Umbraco 19.")]
public IEnumerable<IAuditItem> GetLogs(AuditType type, DateTime? sinceDate = null)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
IEnumerable<IAuditItem> result = sinceDate.HasValue == false
? _auditRepository.Get(type, Query<IAuditItem>())
: _auditRepository.Get(type, Query<IAuditItem>().Where(x => x.CreateDate >= sinceDate.Value));
scope.Complete();
return result;
}
}
public void CleanLogs(int maximumAgeOfLogsInMinutes)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
_auditRepository.CleanLogs(maximumAgeOfLogsInMinutes);
scope.Complete();
}
}
/// <summary>
/// Returns paged items in the audit trail for a given entity
/// </summary>
/// <param name="entityId"></param>
/// <param name="pageIndex"></param>
/// <param name="pageSize"></param>
/// <param name="totalRecords"></param>
/// <param name="orderDirection">
/// By default this will always be ordered descending (newest first)
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter
/// so we need to do that here
/// </param>
/// <param name="customFilter">
/// Optional filter to be applied
/// </param>
/// <returns></returns>
public IEnumerable<IAuditItem> GetPagedItemsByEntity(
int entityId,
long pageIndex,
int pageSize,
out long totalRecords,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null)
{
if (pageIndex < 0)
{
throw new ArgumentOutOfRangeException(nameof(pageIndex));
}
if (pageSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
if (entityId == Constants.System.Root || entityId <= 0)
{
totalRecords = 0;
return Enumerable.Empty<IAuditItem>();
}
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
IQuery<IAuditItem> query = Query<IAuditItem>().Where(x => x.Id == entityId);
return _auditRepository.GetPagedResultsByQuery(query, pageIndex, pageSize, out totalRecords, orderDirection, auditTypeFilter, customFilter);
}
}
/// <summary>
/// Returns paged items in the audit trail for a given user
/// </summary>
/// <param name="userId"></param>
/// <param name="pageIndex"></param>
/// <param name="pageSize"></param>
/// <param name="totalRecords"></param>
/// <param name="orderDirection">
/// By default this will always be ordered descending (newest first)
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter
/// so we need to do that here
/// </param>
/// <param name="customFilter">
/// Optional filter to be applied
/// </param>
/// <returns></returns>
public IEnumerable<IAuditItem> GetPagedItemsByUser(
int userId,
long pageIndex,
int pageSize,
out long totalRecords,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null)
{
if (pageIndex < 0)
{
throw new ArgumentOutOfRangeException(nameof(pageIndex));
}
if (pageSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
if (userId < Constants.Security.SuperUserId)
{
totalRecords = 0;
return Enumerable.Empty<IAuditItem>();
}
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
IQuery<IAuditItem> query = Query<IAuditItem>().Where(x => x.UserId == userId);
return _auditRepository.GetPagedResultsByQuery(query, pageIndex, pageSize, out totalRecords, orderDirection, auditTypeFilter, customFilter);
}
IQuery<IAuditItem>? customFilter = sinceDate.HasValue
? Query<IAuditItem>().Where(x => x.CreateDate >= sinceDate)
: null;
PagedModel<IAuditItem> result = GetItemsInner(
null,
0,
int.MaxValue,
Direction.Ascending,
customFilter: customFilter);
return result.Items;
}
/// <inheritdoc />
public Task<PagedModel<IAuditItem>> GetItemsByKeyAsync(
Guid entityKey,
UmbracoObjectTypes entityType,
@@ -216,33 +141,99 @@ public sealed class AuditService : RepositoryService, IAuditService
DateTimeOffset? sinceDate = null,
AuditType[]? auditTypeFilter = null)
{
if (skip < 0)
{
throw new ArgumentOutOfRangeException(nameof(skip));
}
if (take <= 0)
{
throw new ArgumentOutOfRangeException(nameof(take));
}
ArgumentOutOfRangeException.ThrowIfNegative(skip);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(take);
Attempt<int> keyToIdAttempt = _entityService.GetId(entityKey, entityType);
if (keyToIdAttempt.Success is false)
{
return Task.FromResult(new PagedModel<IAuditItem> { Items = Enumerable.Empty<IAuditItem>(), Total = 0 });
return Task.FromResult(new PagedModel<IAuditItem> { Items = [], Total = 0 });
}
using (ScopeProvider.CreateCoreScope(autoComplete: true))
PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageIndex, out var pageSize);
IQuery<IAuditItem>? customFilter =
sinceDate.HasValue ? Query<IAuditItem>().Where(x => x.CreateDate >= sinceDate) : null;
PagedModel<IAuditItem> result = GetItemsByEntityInner(
keyToIdAttempt.Result,
pageIndex,
pageSize,
orderDirection,
auditTypeFilter,
customFilter);
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<PagedModel<IAuditItem>> GetItemsByEntityAsync(
int entityId,
int skip,
int take,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null)
{
IQuery<IAuditItem> query = Query<IAuditItem>().Where(x => x.Id == keyToIdAttempt.Result);
IQuery<IAuditItem>? customFilter = sinceDate.HasValue ? Query<IAuditItem>().Where(x => x.CreateDate >= sinceDate.Value.LocalDateTime) : null;
PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize);
ArgumentOutOfRangeException.ThrowIfNegative(skip);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(take);
IEnumerable<IAuditItem> auditItems = _auditRepository.GetPagedResultsByQuery(query, pageNumber, pageSize, out var totalRecords, orderDirection, auditTypeFilter, customFilter);
return Task.FromResult(new PagedModel<IAuditItem> { Items = auditItems, Total = totalRecords });
}
if (entityId is Constants.System.Root or <= 0)
{
return Task.FromResult(new PagedModel<IAuditItem> { Items = [], Total = 0 });
}
PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageIndex, out var pageSize);
PagedModel<IAuditItem> result = GetItemsByEntityInner(
entityId,
pageIndex,
pageSize,
orderDirection,
auditTypeFilter,
customFilter);
return Task.FromResult(result);
}
/// <inheritdoc />
[Obsolete("Use GetItemsByEntityAsync() instead. Scheduled for removal in Umbraco 19.")]
public IEnumerable<IAuditItem> GetPagedItemsByEntity(
int entityId,
long pageIndex,
int pageSize,
out long totalRecords,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null)
{
ArgumentOutOfRangeException.ThrowIfNegative(pageIndex);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pageSize);
if (entityId is Constants.System.Root or <= 0)
{
totalRecords = 0L;
return [];
}
PagedModel<IAuditItem> result = GetItemsByEntityInner(
entityId,
pageIndex,
pageSize,
orderDirection,
auditTypeFilter,
customFilter);
totalRecords = result.Total;
return result.Items;
}
/// <inheritdoc />
[Obsolete("Use GetItemsByEntityAsync() instead. Scheduled for removal in Umbraco 19.")]
public IEnumerable<IAuditItem> GetLogs(int objectId)
{
PagedModel<IAuditItem> result = GetItemsByEntityInner(objectId, 0, int.MaxValue, Direction.Ascending);
return result.Items;
}
/// <inheritdoc />
public async Task<PagedModel<IAuditItem>> GetPagedItemsByUserAsync(
Guid userKey,
int skip,
@@ -251,32 +242,76 @@ public sealed class AuditService : RepositoryService, IAuditService
AuditType[]? auditTypeFilter = null,
DateTime? sinceDate = null)
{
if (skip < 0)
{
throw new ArgumentOutOfRangeException(nameof(skip));
}
if (take <= 0)
{
throw new ArgumentOutOfRangeException(nameof(take));
}
ArgumentOutOfRangeException.ThrowIfNegative(skip);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(take);
IUser? user = await _userService.GetAsync(userKey);
if (user is null)
{
return new PagedModel<IAuditItem>();
}
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
IQuery<IAuditItem> query = Query<IAuditItem>().Where(x => x.UserId == user.Id);
IQuery<IAuditItem>? customFilter = sinceDate.HasValue ? Query<IAuditItem>().Where(x => x.CreateDate >= sinceDate) : null;
PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize);
PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageIndex, out var pageSize);
IQuery<IAuditItem>? customFilter =
sinceDate.HasValue ? Query<IAuditItem>().Where(x => x.CreateDate >= sinceDate) : null;
IEnumerable<IAuditItem> auditItems = _auditRepository.GetPagedResultsByQuery(query, pageNumber, pageSize, out var totalRecords, orderDirection, auditTypeFilter, customFilter);
return new PagedModel<IAuditItem> { Items = auditItems, Total = totalRecords };
PagedModel<IAuditItem> result = GetItemsByUserInner(
user.Id,
pageIndex,
pageSize,
orderDirection,
auditTypeFilter,
customFilter);
return result;
}
/// <inheritdoc />
[Obsolete("Use GetPagedItemsByUserAsync() instead. Scheduled for removal in Umbraco 19.")]
public IEnumerable<IAuditItem> GetPagedItemsByUser(
int userId,
long pageIndex,
int pageSize,
out long totalRecords,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null)
{
ArgumentOutOfRangeException.ThrowIfNegative(pageIndex);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pageSize);
if (userId is Constants.System.Root or <= 0)
{
totalRecords = 0L;
return [];
}
PagedModel<IAuditItem> items = GetItemsByUserInner(
userId,
pageIndex,
pageSize,
orderDirection,
auditTypeFilter,
customFilter);
totalRecords = items.Total;
return items.Items;
}
/// <inheritdoc />
[Obsolete("Use GetPagedItemsByUserAsync() instead. Scheduled for removal in Umbraco 19.")]
public IEnumerable<IAuditItem> GetUserLogs(int userId, AuditType type, DateTime? sinceDate = null)
{
IQuery<IAuditItem>? customFilter = sinceDate.HasValue
? Query<IAuditItem>().Where(x => x.AuditType == type && x.CreateDate >= sinceDate)
: null;
PagedModel<IAuditItem> result = GetItemsByUserInner(
userId,
0,
int.MaxValue,
Direction.Ascending,
[type],
customFilter);
return result.Items;
}
/// <inheritdoc />
@@ -305,4 +340,97 @@ public sealed class AuditService : RepositoryService, IAuditService
eventType,
eventDetails).GetAwaiter().GetResult();
}
/// <inheritdoc />
public Task CleanLogsAsync(int maximumAgeOfLogsInMinutes)
{
CleanLogsInner(maximumAgeOfLogsInMinutes);
return Task.CompletedTask;
}
/// <inheritdoc />
[Obsolete("Use CleanLogsAsync() instead. Scheduled for removal in Umbraco 19.")]
public void CleanLogs(int maximumAgeOfLogsInMinutes)
=> CleanLogsInner(maximumAgeOfLogsInMinutes);
private Attempt<AuditLogOperationStatus> AddInner(
AuditType type,
int userId,
int objectId,
string? entityType,
string? comment = null,
string? parameters = null)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
_auditRepository.Save(new AuditItem(objectId, type, userId, entityType, comment, parameters));
scope.Complete();
return Attempt.Succeed(AuditLogOperationStatus.Success);
}
private PagedModel<IAuditItem> GetItemsInner(
IQuery<IAuditItem>? query,
long pageIndex,
int pageSize,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
IEnumerable<IAuditItem> auditItems = _auditRepository.GetPagedResultsByQuery(
query ?? Query<IAuditItem>(),
pageIndex,
pageSize,
out var totalRecords,
orderDirection,
auditTypeFilter,
customFilter);
return new PagedModel<IAuditItem> { Items = auditItems, Total = totalRecords };
}
}
private PagedModel<IAuditItem> GetItemsByEntityInner(
int entityId,
long pageIndex,
int pageSize,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null)
{
IQuery<IAuditItem> query = Query<IAuditItem>().Where(x => x.Id == entityId);
PagedModel<IAuditItem> result = GetItemsInner(
query,
pageIndex,
pageSize,
orderDirection,
auditTypeFilter,
customFilter);
return result;
}
private PagedModel<IAuditItem> GetItemsByUserInner(
int userId,
long pageIndex,
int pageSize,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null)
{
if (userId is < Constants.Security.SuperUserId)
{
return new PagedModel<IAuditItem> { Items = [], Total = 0 };
}
IQuery<IAuditItem> query = Query<IAuditItem>().Where(x => x.UserId == userId);
return GetItemsInner(query, pageIndex, pageSize, orderDirection, auditTypeFilter, customFilter);
}
private void CleanLogsInner(int maximumAgeOfLogsInMinutes)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
_auditRepository.CleanLogs(maximumAgeOfLogsInMinutes);
scope.Complete();
}
}

View File

@@ -1,5 +1,6 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -8,91 +9,71 @@ namespace Umbraco.Cms.Core.Services;
/// </summary>
public interface IAuditService : IService
{
/// <summary>
/// Adds an audit entry.
/// </summary>
/// <param name="type">The type of the audit.</param>
/// <param name="userKey">The key of the user triggering the event.</param>
/// <param name="objectId">The identifier of the affected object.</param>
/// <param name="entityType">The entity type of the affected object.</param>
/// <param name="comment">The comment associated with the audit entry.</param>
/// <param name="parameters">The parameters associated with the audit entry.</param>
/// <returns>Result of the add audit log operation.</returns>
public Task<Attempt<AuditLogOperationStatus>> AddAsync(
AuditType type,
Guid userKey,
int objectId,
string? entityType,
string? comment = null,
string? parameters = null) => throw new NotImplementedException();
[Obsolete("Use AddAsync() instead. Scheduled for removal in Umbraco 19.")]
void Add(AuditType type, int userId, int objectId, string? entityType, string comment, string? parameters = null);
IEnumerable<IAuditItem> GetLogs(int objectId);
IEnumerable<IAuditItem> GetUserLogs(int userId, AuditType type, DateTime? sinceDate = null);
IEnumerable<IAuditItem> GetLogs(AuditType type, DateTime? sinceDate = null);
void CleanLogs(int maximumAgeOfLogsInMinutes);
/// <summary>
/// Returns paged items in the audit trail for a given entity
/// Returns paged items in the audit trail.
/// </summary>
/// <param name="entityId"></param>
/// <param name="pageIndex"></param>
/// <param name="pageSize"></param>
/// <param name="totalRecords"></param>
/// <param name="skip">The number of audit trail entries to skip.</param>
/// <param name="take">The number of audit trail entries to take.</param>
/// <param name="orderDirection">
/// By default this will always be ordered descending (newest first)
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter
/// so we need to do that here
/// </param>
/// <param name="customFilter">
/// Optional filter to be applied
/// </param>
/// <returns></returns>
IEnumerable<IAuditItem> GetPagedItemsByEntity(
int entityId,
long pageIndex,
int pageSize,
out long totalRecords,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null);
/// <summary>
/// Returns paged items in the audit trail for a given user
/// </summary>
/// <param name="userId"></param>
/// <param name="pageIndex"></param>
/// <param name="pageSize"></param>
/// <param name="totalRecords"></param>
/// <param name="orderDirection">
/// By default this will always be ordered descending (newest first)
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter
/// so we need to do that here
/// </param>
/// <param name="customFilter">
/// Optional filter to be applied
/// </param>
/// <returns></returns>
IEnumerable<IAuditItem> GetPagedItemsByUser(
int userId,
long pageIndex,
int pageSize,
out long totalRecords,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null);
/// <summary>
/// Returns paged items in the audit trail for a given entity
/// </summary>
/// <param name="entityKey">The key of the entity</param>
/// <param name="entityType">The entity type</param>
/// <param name="skip">The amount of audit trail entries to skip</param>
/// <param name="take">The amount of audit trail entries to take</param>
/// <param name="orderDirection">
/// By default this will always be ordered descending (newest first)
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter
/// so we need to do that here
/// By default, this will always be ordered descending (newest first).
/// </param>
/// <param name="sinceDate">
/// If populated, will only return entries after this time.
/// </param>
/// <returns></returns>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter, so we need to do that here.
/// </param>
/// <returns>The paged audit logs.</returns>
public Task<PagedModel<IAuditItem>> GetItemsAsync(
int skip,
int take,
Direction orderDirection = Direction.Descending,
DateTimeOffset? sinceDate = null,
AuditType[]? auditTypeFilter = null) => throw new NotImplementedException();
[Obsolete("Use GetItemsAsync() instead. Scheduled for removal in Umbraco 19.")]
IEnumerable<IAuditItem> GetLogs(AuditType type, DateTime? sinceDate = null);
/// <summary>
/// Returns paged items in the audit trail for a given entity.
/// </summary>
/// <param name="entityKey">The key of the entity.</param>
/// <param name="entityType">The entity type.</param>
/// <param name="skip">The number of audit trail entries to skip.</param>
/// <param name="take">The number of audit trail entries to take.</param>
/// <param name="orderDirection">
/// By default, this will always be ordered descending (newest first).
/// </param>
/// <param name="sinceDate">
/// If populated, will only return entries after this time.
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter, so we need to do that here.
/// </param>
/// <returns>The paged items in the audit trail for the specified entity.</returns>
Task<PagedModel<IAuditItem>> GetItemsByKeyAsync(
Guid entityKey,
UmbracoObjectTypes entityType,
@@ -103,21 +84,76 @@ public interface IAuditService : IService
AuditType[]? auditTypeFilter = null) => throw new NotImplementedException();
/// <summary>
/// Returns paged items in the audit trail for a given user
/// Returns paged items in the audit trail for a given entity.
/// </summary>
/// <param name="userKey"></param>
/// <param name="skip"></param>
/// <param name="take"></param>
/// <param name="entityId">The identifier of the entity.</param>
/// <param name="skip">The number of audit trail entries to skip.</param>
/// <param name="take">The number of audit trail entries to take.</param>
/// <param name="orderDirection">
/// By default this will always be ordered descending (newest first)
/// By default, this will always be ordered descending (newest first).
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter
/// so we need to do that here
/// or the custom filter, so we need to do that here.
/// </param>
/// <param name="sinceDate"></param>
/// <returns></returns>
/// <param name="customFilter">
/// Optional filter to be applied.
/// </param>
/// <returns>The paged items in the audit trail for the specified entity.</returns>
public Task<PagedModel<IAuditItem>> GetItemsByEntityAsync(
int entityId,
int skip,
int take,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null) => throw new NotImplementedException();
/// <summary>
/// Returns paged items in the audit trail for a given entity.
/// </summary>
/// <param name="entityId">The identifier of the entity.</param>
/// <param name="pageIndex">The index of tha page (pagination).</param>
/// <param name="pageSize">The number of results to return.</param>
/// <param name="totalRecords">The total number of records.</param>
/// <param name="orderDirection">
/// By default, this will always be ordered descending (newest first).
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter, so we need to do that here.
/// </param>
/// <param name="customFilter">
/// Optional filter to be applied.
/// </param>
/// <returns>The paged items in the audit trail for the specified entity.</returns>
[Obsolete("Use GetItemsByEntityAsync() instead. Scheduled for removal in Umbraco 19.")]
IEnumerable<IAuditItem> GetPagedItemsByEntity(
int entityId,
long pageIndex,
int pageSize,
out long totalRecords,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null);
[Obsolete("Use GetItemsByEntityAsync() instead. Scheduled for removal in Umbraco 19.")]
IEnumerable<IAuditItem> GetLogs(int objectId);
/// <summary>
/// Returns paged items in the audit trail for a given user.
/// </summary>
/// <param name="userKey">The key of the user.</param>
/// <param name="skip">The number of audit trail entries to skip.</param>
/// <param name="take">The number of audit trail entries to take.</param>
/// <param name="orderDirection">
/// By default, this will always be ordered descending (newest first).
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter, so we need to do that here.
/// </param>
/// <param name="sinceDate">The date to filter the audit entries.</param>
/// <returns>The paged items in the audit trail for the specified user.</returns>
Task<PagedModel<IAuditItem>> GetPagedItemsByUserAsync(
Guid userKey,
int skip,
@@ -126,6 +162,38 @@ public interface IAuditService : IService
AuditType[]? auditTypeFilter = null,
DateTime? sinceDate = null) => throw new NotImplementedException();
/// <summary>
/// Returns paged items in the audit trail for a given user.
/// </summary>
/// <param name="userId"></param>
/// <param name="pageIndex"></param>
/// <param name="pageSize"></param>
/// <param name="totalRecords"></param>
/// <param name="orderDirection">
/// By default this will always be ordered descending (newest first).
/// </param>
/// <param name="auditTypeFilter">
/// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query
/// or the custom filter
/// so we need to do that here.
/// </param>
/// <param name="customFilter">
/// Optional filter to be applied.
/// </param>
/// <returns></returns>
[Obsolete("Use GetPagedItemsByUserAsync() instead. Scheduled for removal in Umbraco 19.")]
IEnumerable<IAuditItem> GetPagedItemsByUser(
int userId,
long pageIndex,
int pageSize,
out long totalRecords,
Direction orderDirection = Direction.Descending,
AuditType[]? auditTypeFilter = null,
IQuery<IAuditItem>? customFilter = null);
[Obsolete("Use GetPagedItemsByUserAsync() instead. Scheduled for removal in Umbraco 19.")]
IEnumerable<IAuditItem> GetUserLogs(int userId, AuditType type, DateTime? sinceDate = null);
/// <summary>
/// Writes an audit entry for an audited event.
/// </summary>
@@ -144,7 +212,8 @@ public interface IAuditService : IService
/// </example>
/// </param>
/// <param name="eventDetails">Free-form details about the audited event.</param>
[Obsolete("Use AuditEntryService.WriteAsync() instead. Scheduled for removal in Umbraco 19.")]
/// <returns>The created audit entry.</returns>
[Obsolete("Use IAuditEntryService.WriteAsync() instead. Scheduled for removal in Umbraco 19.")]
IAuditEntry Write(
int performingUserId,
string perfomingDetails,
@@ -154,4 +223,14 @@ public interface IAuditService : IService
string affectedDetails,
string eventType,
string eventDetails);
/// <summary>
/// Cleans the audit logs older than the specified maximum age.
/// </summary>
/// <param name="maximumAgeOfLogsInMinutes">The maximum age of logs in minutes.</param>
/// <returns>Task representing the asynchronous operation.</returns>
public Task CleanLogsAsync(int maximumAgeOfLogsInMinutes) => throw new NotImplementedException();
[Obsolete("Use CleanLogsAsync() instead. Scheduled for removal in Umbraco 19.")]
void CleanLogs(int maximumAgeOfLogsInMinutes);
}

View File

@@ -21,18 +21,17 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
/// </remarks>
public class LogScrubberJob : IRecurringBackgroundJob
{
public TimeSpan Period { get => TimeSpan.FromHours(4); }
// No-op event as the period never changes on this job
public event EventHandler PeriodChanged { add { } remove { } }
private readonly IAuditService _auditService;
private readonly ILogger<LogScrubberJob> _logger;
private readonly IProfilingLogger _profilingLogger;
private readonly ICoreScopeProvider _scopeProvider;
private LoggingSettings _settings;
public TimeSpan Period => TimeSpan.FromHours(4);
// No-op event as the period never changes on this job
public event EventHandler PeriodChanged { add { } remove { } }
/// <summary>
/// Initializes a new instance of the <see cref="LogScrubberJob" /> class.
/// </summary>
@@ -48,7 +47,6 @@ public class LogScrubberJob : IRecurringBackgroundJob
ILogger<LogScrubberJob> logger,
IProfilingLogger profilingLogger)
{
_auditService = auditService;
_settings = settings.CurrentValue;
_scopeProvider = scopeProvider;
@@ -57,17 +55,14 @@ public class LogScrubberJob : IRecurringBackgroundJob
settings.OnChange(x => _settings = x);
}
public Task RunJobAsync()
public async Task RunJobAsync()
{
// Ensure we use an explicit scope since we are running on a background thread.
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
using (_profilingLogger.DebugDuration<LogScrubberJob>("Log scrubbing executing", "Log scrubbing complete"))
{
_auditService.CleanLogs((int)_settings.MaxLogAge.TotalMinutes);
await _auditService.CleanLogsAsync((int)_settings.MaxLogAge.TotalMinutes);
_ = scope.Complete();
}
return Task.CompletedTask;
}
}

View File

@@ -338,11 +338,11 @@ public static partial class UmbracoBuilderExtensions
// add handlers for building content relations
builder
.AddNotificationHandler<ContentCopiedNotification, RelateOnCopyNotificationHandler>()
.AddNotificationAsyncHandler<ContentCopiedNotification, RelateOnCopyNotificationHandler>()
.AddNotificationHandler<ContentMovedNotification, RelateOnTrashNotificationHandler>()
.AddNotificationHandler<ContentMovedToRecycleBinNotification, RelateOnTrashNotificationHandler>()
.AddNotificationAsyncHandler<ContentMovedToRecycleBinNotification, RelateOnTrashNotificationHandler>()
.AddNotificationHandler<MediaMovedNotification, RelateOnTrashNotificationHandler>()
.AddNotificationHandler<MediaMovedToRecycleBinNotification, RelateOnTrashNotificationHandler>();
.AddNotificationAsyncHandler<MediaMovedToRecycleBinNotification, RelateOnTrashNotificationHandler>();
// add notification handlers for property editors
builder

View File

@@ -2,6 +2,8 @@
// See LICENSE for more details.
using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Scoping;
@@ -15,14 +17,17 @@ namespace Umbraco.Cms.Core.Events;
public sealed class RelateOnTrashNotificationHandler :
INotificationHandler<ContentMovedNotification>,
INotificationHandler<ContentMovedToRecycleBinNotification>,
INotificationAsyncHandler<ContentMovedToRecycleBinNotification>,
INotificationHandler<MediaMovedNotification>,
INotificationHandler<MediaMovedToRecycleBinNotification>
INotificationHandler<MediaMovedToRecycleBinNotification>,
INotificationAsyncHandler<MediaMovedToRecycleBinNotification>
{
private readonly IAuditService _auditService;
private readonly IEntityService _entityService;
private readonly IRelationService _relationService;
private readonly ICoreScopeProvider _scopeProvider;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IUserIdKeyResolver _userIdKeyResolver;
private readonly ILocalizedTextService _textService;
public RelateOnTrashNotificationHandler(
@@ -31,7 +36,8 @@ public sealed class RelateOnTrashNotificationHandler :
ILocalizedTextService textService,
IAuditService auditService,
IScopeProvider scopeProvider,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IUserIdKeyResolver userIdKeyResolver)
{
_relationService = relationService;
_entityService = entityService;
@@ -39,6 +45,26 @@ public sealed class RelateOnTrashNotificationHandler :
_auditService = auditService;
_scopeProvider = scopeProvider;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_userIdKeyResolver = userIdKeyResolver;
}
[Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in V19.")]
public RelateOnTrashNotificationHandler(
IRelationService relationService,
IEntityService entityService,
ILocalizedTextService textService,
IAuditService auditService,
IScopeProvider scopeProvider,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
: this(
relationService,
entityService,
textService,
auditService,
scopeProvider,
backOfficeSecurityAccessor,
StaticServiceProvider.Instance.GetRequiredService<IUserIdKeyResolver>())
{
}
public void Handle(ContentMovedNotification notification)
@@ -57,7 +83,8 @@ public sealed class RelateOnTrashNotificationHandler :
}
}
public void Handle(ContentMovedToRecycleBinNotification notification)
/// <inheritdoc />
public async Task HandleAsync(ContentMovedToRecycleBinNotification notification, CancellationToken cancellationToken)
{
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{
@@ -91,9 +118,9 @@ public sealed class RelateOnTrashNotificationHandler :
new Relation(originalParentId, item.Entity.Id, relationType);
_relationService.Save(relation);
_auditService.Add(
await _auditService.AddAsync(
AuditType.Delete,
_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? item.Entity.WriterId,
_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key ?? await _userIdKeyResolver.GetAsync(item.Entity.WriterId),
item.Entity.Id,
UmbracoObjectTypes.Document.GetName(),
string.Format(_textService.Localize("recycleBin", "contentTrashed"), item.Entity.Id, originalParentId));
@@ -104,6 +131,10 @@ public sealed class RelateOnTrashNotificationHandler :
}
}
[Obsolete("Use the INotificationAsyncHandler.HandleAsync implementation instead. Scheduled for removal in V19.")]
public void Handle(ContentMovedToRecycleBinNotification notification)
=> HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult();
public void Handle(MediaMovedNotification notification)
{
foreach (MoveEventInfo<IMedia> item in notification.MoveInfoCollection.Where(x =>
@@ -119,7 +150,8 @@ public sealed class RelateOnTrashNotificationHandler :
}
}
public void Handle(MediaMovedToRecycleBinNotification notification)
/// <inheritdoc />
public async Task HandleAsync(MediaMovedToRecycleBinNotification notification, CancellationToken cancellationToken)
{
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{
@@ -151,9 +183,9 @@ public sealed class RelateOnTrashNotificationHandler :
_relationService.GetByParentAndChildId(originalParentId, item.Entity.Id, relationType) ??
new Relation(originalParentId, item.Entity.Id, relationType);
_relationService.Save(relation);
_auditService.Add(
await _auditService.AddAsync(
AuditType.Delete,
_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? item.Entity.WriterId,
_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key ?? await _userIdKeyResolver.GetAsync(item.Entity.WriterId),
item.Entity.Id,
UmbracoObjectTypes.Media.GetName(),
string.Format(_textService.Localize("recycleBin", "mediaTrashed"), item.Entity.Id, originalParentId));
@@ -163,4 +195,8 @@ public sealed class RelateOnTrashNotificationHandler :
scope.Complete();
}
}
[Obsolete("Use the INotificationAsyncHandler.HandleAsync implementation instead. Scheduled for removal in V19.")]
public void Handle(MediaMovedToRecycleBinNotification notification)
=> HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult();
}

View File

@@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Manifest;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Packaging;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Packaging;
@@ -82,7 +83,13 @@ public class PackagingService : IPackagingService
InstallationSummary summary = _packageInstallation.InstallPackageData(compiledPackage, userId, out _);
_auditService.Add(AuditType.PackagerInstall, userId, -1, "Package", $"Package data installed for package '{compiledPackage.Name}'.");
IUser? user = _userService.GetUserById(userId);
_auditService.AddAsync(
AuditType.PackagerInstall,
user?.Key ?? Constants.Security.SuperUserKey,
-1,
"Package",
$"Package data installed for package '{compiledPackage.Name}'.").GetAwaiter().GetResult();
// trigger the ImportedPackage event
_eventAggregator.Publish(new ImportedPackageNotification(summary).WithStateFrom(importingPackageNotification));
@@ -115,8 +122,12 @@ public class PackagingService : IPackagingService
return Attempt.FailWithStatus<PackageDefinition?, PackageOperationStatus>(PackageOperationStatus.NotFound, null);
}
int currentUserId = (await _userService.GetRequiredUserAsync(userKey)).Id;
_auditService.Add(AuditType.Delete, currentUserId, -1, "Package", $"Created package '{package.Name}' deleted. Package key: {key}");
Attempt<AuditLogOperationStatus> result = await _auditService.AddAsync(AuditType.Delete, userKey, -1, "Package", $"Created package '{package.Name}' deleted. Package key: {key}");
if (result is { Success: false, Result: AuditLogOperationStatus.UserNotFound })
{
throw new InvalidOperationException($"Could not find user with key: {userKey}");
}
_createdPackages.Delete(package.Id);
scope.Complete();
@@ -163,8 +174,11 @@ public class PackagingService : IPackagingService
return Attempt.FailWithStatus(PackageOperationStatus.DuplicateItemName, package);
}
int currentUserId = (await _userService.GetRequiredUserAsync(userKey)).Id;
_auditService.Add(AuditType.New, currentUserId, -1, "Package", $"Created package '{package.Name}' created. Package key: {package.PackageId}");
Attempt<AuditLogOperationStatus> result = await _auditService.AddAsync(AuditType.New, userKey, -1, "Package", $"Created package '{package.Name}' created. Package key: {package.PackageId}");
if (result is { Success: false, Result: AuditLogOperationStatus.UserNotFound })
{
throw new InvalidOperationException($"Could not find user with key: {userKey}");
}
scope.Complete();
@@ -180,8 +194,11 @@ public class PackagingService : IPackagingService
return Attempt.FailWithStatus(PackageOperationStatus.NotFound, package);
}
int currentUserId = (await _userService.GetRequiredUserAsync(userKey)).Id;
_auditService.Add(AuditType.New, currentUserId, -1, "Package", $"Created package '{package.Name}' updated. Package key: {package.PackageId}");
Attempt<AuditLogOperationStatus> result = await _auditService.AddAsync(AuditType.New, userKey, -1, "Package", $"Created package '{package.Name}' updated. Package key: {package.PackageId}");
if (result is { Success: false, Result: AuditLogOperationStatus.UserNotFound })
{
throw new InvalidOperationException($"Could not find user with key: {userKey}");
}
scope.Complete();

View File

@@ -1018,7 +1018,7 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent
}
[Test]
public void Can_Publish_Content_Variation_And_Detect_Changed_Cultures()
public async Task Can_Publish_Content_Variation_And_Detect_Changed_Cultures()
{
CreateEnglishAndFrenchDocumentType(out var langUk, out var langFr, out var contentType);
@@ -1028,7 +1028,7 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent
var published = ContentService.Publish(content, new[] { langFr.IsoCode });
// audit log will only show that french was published
var lastLog = AuditService.GetLogs(content.Id).Last();
var lastLog = (await AuditService.GetItemsByEntityAsync(content.Id, 0, 1)).Items.First();
Assert.AreEqual("Published languages: fr-FR", lastLog.Comment);
// re-get
@@ -1038,7 +1038,7 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent
published = ContentService.Publish(content, new[] { langUk.IsoCode });
// audit log will only show that english was published
lastLog = AuditService.GetLogs(content.Id).Last();
lastLog = (await AuditService.GetItemsByEntityAsync(content.Id, 0, 1)).Items.First();
Assert.AreEqual("Published languages: en-GB", lastLog.Comment);
}
@@ -1075,7 +1075,7 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent
var unpublished = ContentService.Unpublish(content, langFr.IsoCode);
// audit log will only show that french was unpublished
var lastLog = AuditService.GetLogs(content.Id).Last();
var lastLog = (await AuditService.GetItemsByEntityAsync(content.Id, 0, 1)).Items.First();
Assert.AreEqual("Unpublished languages: fr-FR", lastLog.Comment);
// re-get
@@ -1084,7 +1084,7 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent
unpublished = ContentService.Unpublish(content, langGb.IsoCode);
// audit log will only show that english was published
var logs = AuditService.GetLogs(content.Id).ToList();
var logs = (await AuditService.GetItemsByEntityAsync(content.Id, 0, int.MaxValue, Direction.Ascending)).Items.ToList();
Assert.AreEqual("Unpublished languages: en-GB", logs[^2].Comment);
Assert.AreEqual("Unpublished (mandatory language unpublished)", logs[^1].Comment);
}

View File

@@ -1,13 +1,12 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Implement;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
@@ -18,22 +17,26 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
internal sealed class AuditServiceTests : UmbracoIntegrationTest
{
[Test]
public void GetUserLogs()
public async Task GetUserLogs()
{
var sut = (AuditService)Services.GetRequiredService<IAuditService>();
var eventDateUtc = DateTime.UtcNow.AddDays(-1);
var numberOfEntries = 10;
const int numberOfEntries = 10;
for (var i = 0; i < numberOfEntries; i++)
{
eventDateUtc = eventDateUtc.AddMinutes(1);
sut.Add(AuditType.Unpublish, -1, 33, string.Empty, "blah");
await sut.AddAsync(AuditType.Unpublish, Constants.Security.SuperUserKey, 33, string.Empty, "blah");
}
sut.Add(AuditType.Publish, -1, 33, string.Empty, "blah");
await sut.AddAsync(AuditType.Publish, Constants.Security.SuperUserKey, 33, string.Empty, "blah");
var logs = sut.GetUserLogs(-1, AuditType.Unpublish).ToArray();
var logs = (await sut.GetPagedItemsByUserAsync(
Constants.Security.SuperUserKey,
0,
int.MaxValue,
auditTypeFilter: [AuditType.Unpublish])).Items.ToArray();
Assert.Multiple(() =>
{

View File

@@ -25,7 +25,7 @@ public partial class ContentEditingServiceTests : ContentEditingServiceTestsBase
}
protected override void CustomTestSetup(IUmbracoBuilder builder)
=> builder.AddNotificationHandler<ContentCopiedNotification, RelateOnCopyNotificationHandler>();
=> builder.AddNotificationAsyncHandler<ContentCopiedNotification, RelateOnCopyNotificationHandler>();
private ITemplateService TemplateService => GetRequiredService<ITemplateService>();

View File

@@ -0,0 +1,232 @@
using System.Data;
using AutoFixture;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Implement;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services;
[TestFixture]
public class AuditServiceTests
{
private IAuditService _auditService;
private Mock<ICoreScopeProvider> _scopeProviderMock;
private Mock<IAuditRepository> _auditRepositoryMock;
private Mock<IEntityService> _entityServiceMock;
private Mock<IUserService> _userServiceMock;
[SetUp]
public void Setup()
{
_scopeProviderMock = new Mock<ICoreScopeProvider>(MockBehavior.Strict);
_auditRepositoryMock = new Mock<IAuditRepository>(MockBehavior.Strict);
_entityServiceMock = new Mock<IEntityService>(MockBehavior.Strict);
_userServiceMock = new Mock<IUserService>(MockBehavior.Strict);
_auditService = new AuditService(
_scopeProviderMock.Object,
Mock.Of<ILoggerFactory>(MockBehavior.Strict),
Mock.Of<IEventMessagesFactory>(MockBehavior.Strict),
_auditRepositoryMock.Object,
_userServiceMock.Object,
_entityServiceMock.Object);
}
[TestCase(AuditType.Publish, 33, null, null, null)]
[TestCase(AuditType.Copy, 1, "entityType", "comment", "parameters")]
public async Task AddAsync_Calls_Repository_With_Correct_Values(AuditType type, int objectId, string? entityType, string? comment, string? parameters = null)
{
SetupScopeProviderMock();
_auditRepositoryMock.Setup(x => x.Save(It.IsAny<IAuditItem>()))
.Callback<IAuditItem>(item =>
{
Assert.AreEqual(type, item.AuditType);
Assert.AreEqual(Constants.Security.SuperUserId, item.UserId);
Assert.AreEqual(objectId, item.Id);
Assert.AreEqual(entityType, item.EntityType);
Assert.AreEqual(comment, item.Comment);
Assert.AreEqual(parameters, item.Parameters);
});
Mock<IUser> mockUser = new Mock<IUser>();
mockUser.Setup(x => x.Id).Returns(Constants.Security.SuperUserId);
_userServiceMock.Setup(x => x.GetAsync(Constants.Security.SuperUserKey)).ReturnsAsync(mockUser.Object);
var result = await _auditService.AddAsync(
type,
Constants.Security.SuperUserKey,
objectId,
entityType,
comment,
parameters);
_auditRepositoryMock.Verify(x => x.Save(It.IsAny<IAuditItem>()), Times.Once);
Assert.IsTrue(result.Success);
Assert.AreEqual(AuditLogOperationStatus.Success, result.Result);
}
[Test]
public async Task AddAsync_Does_Not_Succeed_When_Non_Existing_User_Is_Provided()
{
_userServiceMock.Setup(x => x.GetAsync(It.IsAny<Guid>())).ReturnsAsync((IUser?)null);
var result = await _auditService.AddAsync(
AuditType.Publish,
Guid.Parse("00000000-0000-0000-0000-000000000001"),
1,
"entityType",
"comment",
"parameters");
Assert.IsFalse(result.Success);
Assert.AreEqual(AuditLogOperationStatus.UserNotFound, result.Result);
}
[Test]
public void GetItemsAsync_Throws_When_Invalid_Pagination_Arguments_Are_Provided()
{
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await _auditService.GetItemsAsync(-1, 10), "Skip must be greater than or equal to 0.");
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await _auditService.GetItemsAsync(0, -1), "Take must be greater than 0.");
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await _auditService.GetItemsAsync(0, 0), "Take must be greater than 0.");
}
[Test]
public async Task GetItemsAsync_Returns_Correct_Total_And_Item_Count()
{
SetupScopeProviderMock();
Fixture fixture = new Fixture();
long totalRecords = 12;
_auditRepositoryMock.Setup(x => x.GetPagedResultsByQuery(
It.IsAny<IQuery<IAuditItem>>(),
2,
5,
out totalRecords,
Direction.Descending,
null,
null))
.Returns(fixture.CreateMany<AuditItem>(count: 2));
_scopeProviderMock.Setup(x => x.CreateQuery<IAuditItem>()).Returns(Mock.Of<IQuery<IAuditItem>>());
var result = await _auditService.GetItemsAsync(10, 5);
Assert.AreEqual(totalRecords, result.Total);
Assert.AreEqual(2, result.Items.Count());
}
[Test]
public void GetItemsByKeyAsync_Throws_When_Invalid_Pagination_Arguments_Are_Provided()
{
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await _auditService.GetItemsByKeyAsync(Guid.Empty, UmbracoObjectTypes.Document, -1, 10), "Skip must be greater than or equal to 0.");
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await _auditService.GetItemsByKeyAsync(Guid.Empty, UmbracoObjectTypes.Document, 0, -1), "Take must be greater than 0.");
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await _auditService.GetItemsByKeyAsync(Guid.Empty, UmbracoObjectTypes.Document, 0, 0), "Take must be greater than 0.");
}
[Test]
public async Task GetItemsByKeyAsync_Returns_No_Results_When_Key_Is_Not_Found()
{
SetupScopeProviderMock();
_entityServiceMock.Setup(x => x.GetId(Guid.Empty, UmbracoObjectTypes.Document)).Returns(Attempt<int>.Fail());
var result = await _auditService.GetItemsByKeyAsync(Guid.Empty, UmbracoObjectTypes.Document, 10, 10);
Assert.AreEqual(0, result.Total);
Assert.AreEqual(0, result.Items.Count());
}
[Test]
public async Task GetItemsByKeyAsync_Returns_Correct_Total_And_Item_Count()
{
SetupScopeProviderMock();
_entityServiceMock.Setup(x => x.GetId(Guid.Empty, UmbracoObjectTypes.Document)).Returns(Attempt.Succeed(2));
Fixture fixture = new Fixture();
long totalRecords = 12;
_auditRepositoryMock.Setup(x => x.GetPagedResultsByQuery(
It.IsAny<IQuery<IAuditItem>>(),
2,
5,
out totalRecords,
Direction.Descending,
null,
null))
.Returns(fixture.CreateMany<AuditItem>(count: 2));
_scopeProviderMock.Setup(x => x.CreateQuery<IAuditItem>())
.Returns(Mock.Of<IQuery<IAuditItem>>());
var result = await _auditService.GetItemsByKeyAsync(Guid.Empty, UmbracoObjectTypes.Document, 10, 5);
Assert.AreEqual(totalRecords, result.Total);
Assert.AreEqual(2, result.Items.Count());
}
[TestCase(Constants.System.Root)]
[TestCase(-100)]
public async Task GetItemsByEntityAsync_Returns_No_Results_When_Id_Is_Root_Or_Lower(int userId)
{
var result = await _auditService.GetItemsByEntityAsync(userId, 10, 10);
Assert.AreEqual(0, result.Total);
Assert.AreEqual(0, result.Items.Count());
}
[Test]
public async Task GetItemsByEntityAsync_Returns_Correct_Total_And_Item_Count()
{
SetupScopeProviderMock();
_entityServiceMock.Setup(x => x.GetId(Guid.Empty, UmbracoObjectTypes.Document)).Returns(Attempt.Succeed(2));
Fixture fixture = new Fixture();
long totalRecords = 12;
_auditRepositoryMock.Setup(x => x.GetPagedResultsByQuery(
It.IsAny<IQuery<IAuditItem>>(),
2,
5,
out totalRecords,
Direction.Descending,
null,
null))
.Returns(fixture.CreateMany<AuditItem>(count: 2));
_scopeProviderMock.Setup(x => x.CreateQuery<IAuditItem>())
.Returns(Mock.Of<IQuery<IAuditItem>>());
var result = await _auditService.GetItemsByEntityAsync(1, 10, 5);
Assert.AreEqual(totalRecords, result.Total);
Assert.AreEqual(2, result.Items.Count());
}
[Test]
public async Task CleanLogsAsync_Calls_Repository_With_Correct_Values()
{
SetupScopeProviderMock();
_auditRepositoryMock.Setup(x => x.CleanLogs(100));
await _auditService.CleanLogsAsync(100);
_auditRepositoryMock.Verify(x => x.CleanLogs(It.IsAny<int>()), Times.Once);
}
private void SetupScopeProviderMock() =>
_scopeProviderMock
.Setup(x => x.CreateCoreScope(
It.IsAny<IsolationLevel>(),
It.IsAny<RepositoryCacheMode>(),
It.IsAny<IEventDispatcher>(),
It.IsAny<IScopedNotificationPublisher>(),
It.IsAny<bool?>(),
It.IsAny<bool>(),
It.IsAny<bool>()))
.Returns(Mock.Of<IScope>());
}

View File

@@ -68,5 +68,5 @@ public class LogScrubberJobTests
private void VerifyLogsScrubbed() => VerifyLogsScrubbed(Times.Once());
private void VerifyLogsScrubbed(Times times) =>
_mockAuditService.Verify(x => x.CleanLogs(It.Is<int>(y => y == MaxLogAgeInMinutes)), times);
_mockAuditService.Verify(x => x.CleanLogsAsync(It.Is<int>(y => y == MaxLogAgeInMinutes)), times);
}