158 lines
6.6 KiB
C#
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 });
|
||
|
|
}
|
||
|
|
}
|