Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ public TenantIntegrationTestFixture()

builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<FacilityIdSettings>>().Value);

// Stub producer for AuditEventCommand
builder.Services.AddSingleton<IProducer<string, AuditEventMessage>>(new StubProducer<string, AuditEventMessage>());
// Recording producer for AuditEventCommand, so tests can assert on emitted audit events.
builder.Services.AddSingleton<RecordingAuditProducer>();
builder.Services.AddSingleton<IProducer<string, AuditEventMessage>>(sp => sp.GetRequiredService<RecordingAuditProducer>());

// Add the real CreateAuditEventCommand
builder.Services.AddSingleton<CreateAuditEventCommand>();
Expand Down Expand Up @@ -210,13 +211,58 @@ public Task<string> ExecuteAsync(string signingKey, int expirationMinutes)
}
}

private class StubProducer<TKey, TValue> : IProducer<TKey, TValue>
/// <summary>
/// Captures audit events instead of discarding them. CreateAuditEventCommand.Execute is
/// async void, so a test awaits <see cref="WaitForAsync"/> rather than assuming the
/// message has landed by the time the manager call returns.
/// </summary>
public class RecordingAuditProducer : StubProducer<string, AuditEventMessage>
{
private readonly List<(string Key, AuditEventMessage Value)> _produced = new();

public IReadOnlyList<(string Key, AuditEventMessage Value)> Produced
{
get { lock (_produced) { return _produced.ToList(); } }
}

public void Clear()
{
lock (_produced) { _produced.Clear(); }
}

public override Task<DeliveryResult<string, AuditEventMessage>> ProduceAsync(
string topic, Message<string, AuditEventMessage> message, CancellationToken cancellationToken = default)
{
lock (_produced) { _produced.Add((message.Key, message.Value)); }
return base.ProduceAsync(topic, message, cancellationToken);
}

public async Task<IReadOnlyList<(string Key, AuditEventMessage Value)>> WaitForAsync(
int count, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var produced = Produced;
if (produced.Count >= count)
{
return produced;
}

await Task.Delay(20);
}

return Produced;
}
}

public class StubProducer<TKey, TValue> : IProducer<TKey, TValue>
{
public Handle Handle => null;

public string Name => "stub";

public Task<DeliveryResult<TKey, TValue>> ProduceAsync(string topic, Message<TKey, TValue> message, CancellationToken cancellationToken = default)
public virtual Task<DeliveryResult<TKey, TValue>> ProduceAsync(string topic, Message<TKey, TValue> message, CancellationToken cancellationToken = default)
{
return Task.FromResult(new DeliveryResult<TKey, TValue>
{
Expand Down
133 changes: 133 additions & 0 deletions DotNet/ServiceTests/IntegrationTests/Tenant/VendorAuditTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using LantanaGroup.Link.Sdk.ApiClient;
using LantanaGroup.Link.Sdk.Clients;
using LantanaGroup.Link.Shared.Application.Models;
using LantanaGroup.Link.Shared.Application.Models.Integration.Normalization;
using LantanaGroup.Link.Shared.Application.Models.Kafka;
using LantanaGroup.Link.Shared.Application.Models.Tenant;
using LantanaGroup.Link.Tenant.Business.Managers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Task = System.Threading.Tasks.Task;

namespace IntegrationTests.Tenant;

[Collection("IntegrationTests")]
[Trait("Category", "IntegrationTests")]
public class VendorAuditTests : IDisposable
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);

private readonly IServiceScope _scope;
private readonly IVendorManager _vendorManager;
private readonly TenantIntegrationTestFixture.RecordingAuditProducer _auditProducer;

public VendorAuditTests(TenantIntegrationTestFixture fixture)
{
_scope = fixture.ServiceProvider.CreateScope();
var serviceProvider = _scope.ServiceProvider;

_vendorManager = serviceProvider.GetRequiredService<IVendorManager>();
_auditProducer = serviceProvider.GetRequiredService<TenantIntegrationTestFixture.RecordingAuditProducer>();
_auditProducer.Clear();

// Deleting a vendor checks Normalization for references to each of its versions.
var normalizationServiceClient = serviceProvider.GetRequiredService<Mock<INormalizationServiceClient>>();
normalizationServiceClient.Reset();
normalizationServiceClient
.Setup(client => client.GetVendorVersionOperationPresetsAsync(
It.IsAny<Guid>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new LinkApiResponse<List<NormalizationVendorVersionOperationPresetApiModel>>
{
StatusCode = StatusCodes.Status200OK,
Body = []
});
}

public void Dispose() => _scope.Dispose();

[Fact]
public async Task CreateVendor_EmitsACreateAuditEvent()
{
var created = await _vendorManager.CreateVendorAsync(new VendorModel { Name = $"Vendor-{Guid.NewGuid():N}" });

var events = await _auditProducer.WaitForAsync(1, Timeout);

var audit = Assert.Single(events);
Assert.Equal(AuditEventType.Create, audit.Value.Action);
Assert.Equal("Vendor", audit.Value.Resource);
Assert.Equal(created.Id.ToString(), audit.Key);
}

[Fact]
public async Task UpdateVendor_RecordsTheSigningKeySecretIdChange()
{
var created = await _vendorManager.CreateVendorAsync(new VendorModel { Name = $"Vendor-{Guid.NewGuid():N}" });
_auditProducer.Clear();

await _vendorManager.UpdateVendorAsync(created.Id!.Value, new VendorModel
{
Name = created.Name,
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = "epic-signing-key" }
});

var events = await _auditProducer.WaitForAsync(1, Timeout);

var audit = Assert.Single(events);
Assert.Equal(AuditEventType.Update, audit.Value.Action);
var change = Assert.Single(audit.Value.PropertyChanges!,
c => c.PropertyName == nameof(VendorAuthenticationSettings.SigningKeySecretId));
Assert.Null(change.InitialPropertyValue);
Assert.Equal("epic-signing-key", change.NewPropertyValue);
}

[Fact]
public async Task UpdateVendor_RecordsAClearedSigningKeySecretId()
{
var created = await _vendorManager.CreateVendorAsync(new VendorModel
{
Name = $"Vendor-{Guid.NewGuid():N}",
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = "epic-signing-key" }
});
_auditProducer.Clear();

await _vendorManager.UpdateVendorAsync(created.Id!.Value, new VendorModel
{
Name = created.Name,
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = null }
});

var events = await _auditProducer.WaitForAsync(1, Timeout);

var change = Assert.Single(Assert.Single(events).Value.PropertyChanges!,
c => c.PropertyName == nameof(VendorAuthenticationSettings.SigningKeySecretId));
Assert.Equal("epic-signing-key", change.InitialPropertyValue);
Assert.Null(change.NewPropertyValue);
}

[Fact]
public async Task UpdateVendor_ThatChangesNothing_EmitsNoAuditEvent()
{
var created = await _vendorManager.CreateVendorAsync(new VendorModel { Name = $"Vendor-{Guid.NewGuid():N}" });
_auditProducer.Clear();

await _vendorManager.UpdateVendorAsync(created.Id!.Value, new VendorModel { Name = created.Name });

await Task.Delay(200);

Assert.Empty(_auditProducer.Produced);
}

[Fact]
public async Task DeleteVendor_EmitsADeleteAuditEvent()
{
var created = await _vendorManager.CreateVendorAsync(new VendorModel { Name = $"Vendor-{Guid.NewGuid():N}" });
_auditProducer.Clear();

await _vendorManager.DeleteVendorAsync(created.Id!.Value);

var events = await _auditProducer.WaitForAsync(1, Timeout);

Assert.Equal(AuditEventType.Delete, Assert.Single(events).Value.Action);
}
}
29 changes: 28 additions & 1 deletion DotNet/Tenant/Business/Managers/VendorManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,18 @@ public class VendorManager : IVendorManager
private readonly ILogger<VendorManager> _logger;
private readonly TenantDbContext _dbContext;
private readonly INormalizationServiceClient _normalizationServiceClient;
private readonly CreateAuditEventCommand _createAuditEventCommand;

public VendorManager(
ILogger<VendorManager> logger,
TenantDbContext dbContext,
INormalizationServiceClient normalizationServiceClient)
INormalizationServiceClient normalizationServiceClient,
CreateAuditEventCommand createAuditEventCommand)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_normalizationServiceClient = normalizationServiceClient ?? throw new ArgumentNullException(nameof(normalizationServiceClient));
_createAuditEventCommand = createAuditEventCommand ?? throw new ArgumentNullException(nameof(createAuditEventCommand));
}

public async Task<VendorModel> CreateVendorAsync(VendorModel newVendor, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -89,6 +92,10 @@ public async Task<VendorModel> CreateVendorAsync(VendorModel newVendor, Cancella
await _dbContext.Vendors.AddAsync(vendorEntity, cancellationToken);
await _dbContext.VendorVersions.AddAsync(vendorVersionEntity, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);

_createAuditEventCommand.Execute(
vendorEntity.Id.ToString(), Helper.CreateVendorAuditEvent(vendorEntity), cancellationToken);

return new VendorModel
{
Id = vendorEntity.Id,
Expand Down Expand Up @@ -148,7 +155,16 @@ public async Task DeleteVendorAsync(Guid vendorId, CancellationToken cancellatio
await EnsureVendorVersionIsNotReferencedByNormalizationAsync(vendorVersionId, cancellationToken);
}

var vendor = await _dbContext.Vendors.AsNoTracking()
.FirstOrDefaultAsync(v => v.Id == vendorId, cancellationToken);

await _dbContext.Vendors.Where(q => q.Id == vendorId).ExecuteDeleteAsync(cancellationToken);

if (vendor != null)
{
_createAuditEventCommand.Execute(
vendor.Id.ToString(), Helper.DeleteVendorAuditEvent(vendor), cancellationToken);
}
}

public async Task DeleteVendorVersionAsync(Guid vendorVersionId, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -187,12 +203,23 @@ public async Task<VendorModel> UpdateVendorAsync(Guid id, VendorModel vendor, Ca
throw new InvalidOperationException($"Vendor with ID '{id}' does not exist.");
}

string? previousName = existingVendor.Name;
string? previousSigningKeySecretId = existingVendor.Authentication?.SigningKeySecretId;

existingVendor.Name = vendor.Name ?? existingVendor.Name;

existingVendor.Authentication = vendor.Authentication ?? existingVendor.Authentication;

_dbContext.Vendors.Update(existingVendor);
await _dbContext.SaveChangesAsync(cancellationToken);

AuditEventMessage? auditEvent =
Helper.UpdateVendorAuditEvent(existingVendor, previousName, previousSigningKeySecretId);
if (auditEvent != null)
{
_createAuditEventCommand.Execute(existingVendor.Id.ToString(), auditEvent, cancellationToken);
}

return new VendorModel
{
Id = existingVendor.Id,
Expand Down
68 changes: 68 additions & 0 deletions DotNet/Tenant/Utils/Helper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
ο»Ώusing KellermanSoftware.CompareNetObjects;
using LantanaGroup.Link.Shared.Application.Models;
using LantanaGroup.Link.Shared.Application.Models.Kafka;
using LantanaGroup.Link.Shared.Application.Models.Tenant;
using LantanaGroup.Link.Tenant.Config;
using LantanaGroup.Link.Tenant.Entities;
using LantanaGroup.Link.Tenant.Models;
Expand All @@ -26,6 +27,73 @@ public static AuditEventMessage CreateFacilityAuditEvent(Facility facility)
return auditEvent;
}

public static AuditEventMessage CreateVendorAuditEvent(Vendor vendor)
{
AuditEventMessage auditEvent = VendorAuditEvent(vendor, AuditEventType.Create);
auditEvent.Notes = $"New vendor ({vendor.Id}) created for '{vendor.Name}'";
return auditEvent;
}

public static AuditEventMessage DeleteVendorAuditEvent(Vendor vendor)
{
AuditEventMessage auditEvent = VendorAuditEvent(vendor, AuditEventType.Delete);
auditEvent.Notes = $"Deleted vendor ({vendor.Id}) '{vendor.Name}'";
return auditEvent;
}

/// <summary>
/// Returns null when nothing changed, so an update that alters neither the name nor the
/// signing key does not raise an audit event.
/// </summary>
public static AuditEventMessage? UpdateVendorAuditEvent(Vendor updatedVendor, string? existingName, string? existingSigningKeySecretId)
{
List<PropertyChangeModel> changes = new();

if (updatedVendor.Name != existingName)
{
changes.Add(new PropertyChangeModel
{
PropertyName = nameof(Vendor.Name),
InitialPropertyValue = existingName,
NewPropertyValue = updatedVendor.Name
});
}

string? updatedSigningKeySecretId = updatedVendor.Authentication?.SigningKeySecretId;
if (updatedSigningKeySecretId != existingSigningKeySecretId)
{
changes.Add(new PropertyChangeModel
{
PropertyName = nameof(VendorAuthenticationSettings.SigningKeySecretId),
InitialPropertyValue = existingSigningKeySecretId,
NewPropertyValue = updatedSigningKeySecretId
});
}

if (changes.Count == 0)
{
return null;
}

AuditEventMessage auditEvent = VendorAuditEvent(updatedVendor, AuditEventType.Update);
auditEvent.PropertyChanges = changes;
auditEvent.Notes = $"Updated vendor ({updatedVendor.Id}) '{updatedVendor.Name}'";
return auditEvent;
}

private static AuditEventMessage VendorAuditEvent(Vendor vendor, AuditEventType action)
{
return new AuditEventMessage
{
ServiceName = TenantConstants.ServiceName,
EventDate = DateTime.UtcNow,
User = "SystemUser",
Action = action,
Resource = typeof(Vendor).Name,
CorrelationId = Guid.NewGuid().ToString()
};
}

public static AuditEventMessage UpdateFacilityAuditEvent(Facility updatedfacility, Facility existingFacility)
{
CompareLogic compareLogic = new CompareLogic();
Expand Down
Loading