Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Products/ProductPricesAuditFailureTests.cs

158 lines
6.6 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.TestSupport;
using Xunit;
namespace SIGCM2.Api.Tests.Products;
/// <summary>
/// PRD-003 — Batch 7 / T7.4: Audit failure e2e test.
///
/// Verifies that when IAuditLogger.LogAsync throws, the TransactionScope rolls back:
/// - dbo.ProductPrices row is NOT inserted (fail-closed).
/// - dbo.AuditEvent row is NOT created.
/// - HTTP response is 500 (unhandled exception propagates through ExceptionFilter).
///
/// Uses CreateClientWithOverrides (pattern from issue #36) to inject a throwing IAuditLogger
/// mock without touching the shared factory.
///
/// DB: SIGCM2_Test_Api (ApiIntegration collection).
/// </summary>
[Collection("ApiIntegration")]
public sealed class ProductPricesAuditFailureTests : IAsyncLifetime
{
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
private readonly TestWebAppFactory _factory;
private int _medioId;
private int _productTypeId;
public ProductPricesAuditFailureTests(TestWebAppFactory factory)
{
_factory = factory;
}
public async Task InitializeAsync()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
_medioId = await conn.QuerySingleAsync<int>(
"SELECT TOP 1 Id FROM dbo.Medio WHERE Activo = 1 ORDER BY Id");
var ptId = await conn.QuerySingleOrDefaultAsync<int?>(
"SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id");
if (ptId is null)
{
ptId = await conn.QuerySingleAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages, IsActive, FechaCreacion)
VALUES ('PT_AuditFail_Test', 0, 0, 0, 0, 0, 1, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS INT);
""");
}
_productTypeId = ptId.Value;
}
public Task DisposeAsync() => Task.CompletedTask;
// ── T7.4 — Audit failure rolls back the ProductPrice insert ──────────────
[Fact]
public async Task PostPrice_WhenAuditLoggerThrows_Returns500AndRollsBackInsert()
{
// Arrange: seed a unique product
var productId = await SeedProductAsync();
// Count rows before the failing POST
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var priceCountBefore = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
new { ProductId = productId });
var auditCountBefore = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'product_price.created' AND TargetType = 'ProductPrice'");
// Build a mock IAuditLogger that throws on LogAsync
var throwingAuditLogger = Substitute.For<IAuditLogger>();
throwingAuditLogger
.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("audit down — simulated failure"));
// Use CreateClientWithOverrides to inject the mock without touching the shared factory
using var client = _factory.CreateClientWithOverrides(services =>
{
services.RemoveAll<IAuditLogger>();
services.AddScoped<IAuditLogger>(_ => throwingAuditLogger);
});
// Generate admin token (use factory services, not child)
var jwt = _factory.Services.GetRequiredService<IJwtService>();
var token = jwt.GenerateAccessToken(new Usuario(
id: 1, username: "admin", passwordHash: "x",
nombre: "Admin", apellido: "Sys", email: null,
rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = JsonContent.Create(new { price = 999m, priceValidFrom = pvf });
// Act
var resp = await client.SendAsync(req);
// Assert HTTP: audit failure → unhandled exception → 500
resp.StatusCode.Should().Be(HttpStatusCode.InternalServerError,
because: "IAuditLogger throwing must propagate as 500 (fail-closed)");
// Assert DB: NO new ProductPrices row was persisted (TransactionScope rolled back)
await using var verifyConn = new SqlConnection(ConnectionString);
await verifyConn.OpenAsync();
var priceCountAfter = await verifyConn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
new { ProductId = productId });
priceCountAfter.Should().Be(priceCountBefore,
because: "TransactionScope must roll back the ProductPrices INSERT when audit fails");
// Assert DB: NO new AuditEvent row for this product
var auditCountAfter = await verifyConn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'product_price.created' AND TargetType = 'ProductPrice'");
auditCountAfter.Should().Be(auditCountBefore,
because: "no AuditEvent row must be created when the audit logger throws");
}
// ── Helper: seed an active product ───────────────────────────────────────
private async Task<int> SeedProductAsync()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var nombre = $"PP_AF_{Guid.NewGuid():N}"[..35];
return await conn.QuerySingleAsync<int>("""
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, PriceDurationDays, IsActive, FechaCreacion)
VALUES (@Nombre, @MedioId, @PtId, 100.00, NULL, 1, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS INT);
""",
new { Nombre = nombre, MedioId = _medioId, PtId = _productTypeId });
}
}