c5c33f7023
CI / backend (push) Successful in 53s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 28s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 59s
CI / backend (pull_request) Successful in 51s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 56s
173 lines
7.1 KiB
C#
173 lines
7.1 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 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",
|
|
["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);
|
|
}
|
|
}
|