Files
Inboxintel/tests/InboxIntel.IntegrationTests/AuditFixesTests.cs
cesnimda a3d8654198
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 48s
CI / db-tests (push) Successful in 51s
Deploy Staging / deploy (push) Successful in 26s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 46s
CI / db-tests (pull_request) Successful in 50s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 59s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 57s
feat!: migrate to .NET 10 LTS (#28)
2026-07-02 16:53:24 +02:00

176 lines
7.4 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using FluentAssertions;
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
namespace InboxIntel.IntegrationTests;
// ── Tests proving the Phase-2 audit fixes (see AUDIT_REPORT.md) ────────────────────────────
/// <summary>Pass-through auth scheme so integration tests can exercise authenticated
/// endpoints (model validation, per-user rate limits) without a real Google login.</summary>
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public new const string Scheme = "Test";
// Stable across requests so per-user rate-limit partitions accumulate correctly.
public static readonly string Uid = Guid.NewGuid().ToString();
public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> o, ILoggerFactory l, UrlEncoder e)
: base(o, l, e) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "test-sub"),
new Claim("inboxintel:uid", Uid),
}, Scheme);
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme)));
}
}
/// <summary>Factory with the test auth scheme + tiny rate-limit windows so limits trip fast.</summary>
public class AuditTestAppFactory : WebApplicationFactory<Program>
{
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["Database:AutoMigrate"] = "false",
// Npgsql 10 eagerly validates the connection string when the DbContext is
// resolved (8.x was lazy); these tests never connect, but the string must parse.
["ConnectionStrings:Postgres"] = "Host=localhost;Database=test;Username=test;Password=test",
["GoogleOAuth:ClientId"] = "test-client-id",
["GoogleOAuth:ClientSecret"] = "test-client-secret",
// H-2: make the auth policy trip on the 3rd request within the window.
["RateLimiting:AuthPermitLimit"] = "2",
["RateLimiting:WindowSeconds"] = "60",
}));
return base.CreateHost(builder);
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddAuthentication(TestAuthHandler.Scheme)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.Scheme, _ => { });
services.PostConfigure<AuthenticationOptions>(o =>
{
o.DefaultAuthenticateScheme = TestAuthHandler.Scheme;
o.DefaultChallengeScheme = TestAuthHandler.Scheme;
});
});
}
}
public class AuditFixesTests : IClassFixture<AuditTestAppFactory>
{
private readonly AuditTestAppFactory _factory;
public AuditFixesTests(AuditTestAppFactory factory) => _factory = factory;
// H-1: FluentValidation auto-validation now rejects invalid DTOs at the boundary with 400
// (previously the registered validators never executed).
[Fact]
public async Task Invalid_search_request_is_rejected_with_400_by_the_validator()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/api/v1/search", new { page = 1, pageSize = 0 });
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Search_with_from_after_to_is_rejected_with_400()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/api/v1/search",
new { page = 1, pageSize = 10, from = "2026-02-01", to = "2026-01-01" });
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// H-2: the "auth" rate-limit policy returns 429 once the per-window permit is exhausted.
[Fact]
public async Task Auth_endpoint_rate_limits_with_429_after_the_permit_is_exhausted()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var s1 = (await client.GetAsync("/api/v1/auth/login")).StatusCode;
var s2 = (await client.GetAsync("/api/v1/auth/login")).StatusCode;
var s3 = (await client.GetAsync("/api/v1/auth/login")).StatusCode;
s1.Should().NotBe(HttpStatusCode.TooManyRequests);
s2.Should().NotBe(HttpStatusCode.TooManyRequests);
s3.Should().Be(HttpStatusCode.TooManyRequests);
}
}
// M-1: absolute session lifetime — a session older than the cap (or missing its issued
// stamp) is expired regardless of sliding renewal.
public class SessionLifetimeTests
{
private static readonly DateTimeOffset Now = new(2026, 07, 02, 12, 0, 0, TimeSpan.Zero);
private static readonly TimeSpan Max = TimeSpan.FromDays(30);
[Fact]
public void Fresh_session_is_not_expired()
=> SessionLifetime.IsExpired(Now.AddDays(-1).ToString("O"), Now, Max).Should().BeFalse();
[Fact]
public void Session_older_than_the_cap_is_expired()
=> SessionLifetime.IsExpired(Now.AddDays(-31).ToString("O"), Now, Max).Should().BeTrue();
[Fact]
public void Session_without_a_stamp_is_expired()
=> SessionLifetime.IsExpired(null, Now, Max).Should().BeTrue();
}
// M-6: EmailLabel now carries a matching tenant query filter (via its Email navigation), so
// join rows can never leak across users even without a manual Where.
public class EmailLabelFilterTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId { get; set; }
public bool IsAuthenticated => UserId != Guid.Empty;
}
[Fact]
public async Task EmailLabels_are_invisible_across_users()
{
var userA = Guid.NewGuid();
var userB = Guid.NewGuid();
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(nameof(EmailLabels_are_invisible_across_users)).Options;
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
{
var emailA = new Email { UserId = userA, GmailMessageId = "a1" };
var emailB = new Email { UserId = userB, GmailMessageId = "b1" };
var labelA = new Label { UserId = userA, GmailLabelId = "LA", Name = "A" };
var labelB = new Label { UserId = userB, GmailLabelId = "LB", Name = "B" };
seed.AddRange(emailA, emailB, labelA, labelB,
new EmailLabel { EmailId = emailA.Id, LabelId = labelA.Id },
new EmailLabel { EmailId = emailB.Id, LabelId = labelB.Id });
await seed.SaveChangesAsync();
}
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = userA });
var visible = await ctx.Set<EmailLabel>().ToListAsync(); // no manual Where — filter must enforce
visible.Should().HaveCount(1);
}
}