From c40e9c675cdf64e7f3a2c03c1d0227abc4ce7d6e Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 14:50:06 -0300 Subject: [PATCH 1/2] test(api): HTTP integration tests for pending-operations endpoints (#81) Add PendingOperationsControllerTests driving the full agent confirmation flow over real HTTP against Postgres: confirm -> step-up -> step-up/verify -> execute, anchored on the api_keys.manage (StepUp) capability. Adds a capturing IEmailService fake (CapturingEmailService) to the test factory via ConfigureTestServices so the step-up code (emailed only) is readable by the test. Asserts confirmation-token persistence, step-up challenge issuance + 60s service cooldown, valid/invalid code verification, the auth rate limiter on repeated verifies, execute-time step_up_required enforcement, and the full happy path creating a real API key. Refs thomasluizon/orbit-ui-mobile#81 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../IntegrationTestWebApplicationFactory.cs | 26 ++ .../PendingOperationsControllerTests.cs | 326 ++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 tests/Orbit.IntegrationTests/PendingOperationsControllerTests.cs diff --git a/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs b/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs index 6c0bca13..3fa0eb08 100644 --- a/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs +++ b/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs @@ -2,7 +2,9 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Orbit.Application.Common; +using Orbit.Domain.Interfaces; namespace Orbit.IntegrationTests; @@ -36,6 +38,8 @@ static IntegrationTestWebApplicationFactory() /// public CapturingBillingService BillingService { get; } = new(); + public CapturingEmailService Email { get; } = new(); + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseSetting("Jwt:SecretKey", "OrbitIntegrationTestSecretKey-0123456789-ABCDEF"); @@ -43,6 +47,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureTestServices(services => { services.AddScoped(_ => BillingService); + services.RemoveAll(); + services.AddSingleton(Email); }); } @@ -72,3 +78,23 @@ private static string BuildClientIpAddress() return $"198.51.{thirdOctet}.{fourthOctet}"; } } + +public sealed class CapturingEmailService : IEmailService +{ + public string? LastVerificationCode { get; private set; } + + public Task SendWelcomeEmailAsync(string toEmail, string userName, string language = "en", CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SendVerificationCodeAsync(string toEmail, string code, string language = "en", CancellationToken cancellationToken = default) + { + LastVerificationCode = code; + return Task.CompletedTask; + } + + public Task SendSupportEmailAsync(string fromName, string fromEmail, string subject, string message, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SendAccountDeletionCodeAsync(string toEmail, string code, string language = "en", CancellationToken cancellationToken = default) + => Task.CompletedTask; +} diff --git a/tests/Orbit.IntegrationTests/PendingOperationsControllerTests.cs b/tests/Orbit.IntegrationTests/PendingOperationsControllerTests.cs new file mode 100644 index 00000000..6f5c2cae --- /dev/null +++ b/tests/Orbit.IntegrationTests/PendingOperationsControllerTests.cs @@ -0,0 +1,326 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.IntegrationTests; + +[Collection("Sequential")] +public class PendingOperationsControllerTests : IAsyncLifetime +{ + private const string CreateApiKeyOperationId = "manage_api_keys"; + private const string CreateApiKeyArgumentsJson = "{\"action\":\"create\",\"name\":\"Claude\"}"; + private const string TestCode = "999999"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly IntegrationTestWebApplicationFactory _factory; + private readonly HttpClient _client; + private readonly string _email = $"pendingops-test-{Guid.NewGuid()}@integration.test"; + private Guid _userId; + + public PendingOperationsControllerTests(IntegrationTestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + IntegrationTestHelpers.RegisterTestAccount(_email, TestCode); + } + + public async Task InitializeAsync() + { + var login = await IntegrationTestHelpers.AuthenticateWithCodeAsync(_client, _email, TestCode, JsonOptions); + _userId = login.UserId; + + // The api_keys.manage capability is plan-gated to Pro; an active trial grants Pro access + // so policy evaluation reaches the step-up gate instead of denying on plan. + await GrantProAccessAsync(); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + // ── confirm ────────────────────────────────────────────── + + [Fact] + public async Task Confirm_PersistsConfirmationToken() + { + var pendingOperationId = SeedStepUpPendingOperation(); + + var response = await _client.PostAsync($"/api/ai/pending-operations/{pendingOperationId}/confirm", null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var confirmation = await response.Content.ReadFromJsonAsync(JsonOptions); + confirmation.Should().NotBeNull(); + confirmation!.PendingOperationId.Should().Be(pendingOperationId); + confirmation.ConfirmationToken.Should().NotBeNullOrEmpty(); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var stored = await dbContext.PendingAgentOperations + .AsNoTracking() + .SingleAsync(item => item.Id == pendingOperationId); + stored.ConfirmedAtUtc.Should().NotBeNull(); + stored.ConfirmationTokenHash.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Confirm_UnknownId_ReturnsNotFound() + { + var response = await _client.PostAsync($"/api/ai/pending-operations/{Guid.NewGuid()}/confirm", null); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + body!.Error.Should().NotBeNullOrEmpty(); + } + + // ── step-up ────────────────────────────────────────────── + + [Fact] + public async Task StepUp_IssuesChallenge_AndEmailsCode() + { + var pendingOperationId = SeedStepUpPendingOperation(); + + var response = await PostStepUpAsync(pendingOperationId); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var challenge = await response.Content.ReadFromJsonAsync(JsonOptions); + challenge.Should().NotBeNull(); + challenge!.ChallengeId.Should().NotBeEmpty(); + challenge.PendingOperationId.Should().Be(pendingOperationId); + + _factory.Email.LastVerificationCode.Should().NotBeNull(); + _factory.Email.LastVerificationCode.Should().HaveLength(6); + } + + [Fact] + public async Task StepUp_SecondRequestWithinCooldown_ReturnsBadRequest() + { + var pendingOperationId = SeedStepUpPendingOperation(); + + var first = await PostStepUpAsync(pendingOperationId); + first.StatusCode.Should().Be(HttpStatusCode.OK); + + var second = await PostStepUpAsync(pendingOperationId); + + second.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await second.Content.ReadFromJsonAsync(JsonOptions); + body!.Error.Should().Be("Please wait before requesting another step-up code."); + } + + // ── step-up/verify ─────────────────────────────────────── + + [Fact] + public async Task StepUpVerify_ValidCode_Succeeds() + { + var pendingOperationId = SeedStepUpPendingOperation(); + var challenge = await IssueChallengeAsync(pendingOperationId); + + var response = await PostStepUpVerifyAsync(pendingOperationId, challenge.ChallengeId, _factory.Email.LastVerificationCode!); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var stored = await dbContext.PendingAgentOperations + .AsNoTracking() + .SingleAsync(item => item.Id == pendingOperationId); + stored.StepUpSatisfiedAtUtc.Should().NotBeNull(); + } + + [Fact] + public async Task StepUpVerify_InvalidCode_ReturnsBadRequest() + { + var pendingOperationId = SeedStepUpPendingOperation(); + var challenge = await IssueChallengeAsync(pendingOperationId); + + var response = await PostStepUpVerifyAsync(pendingOperationId, challenge.ChallengeId, "000000"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + body!.Error.Should().Be("Invalid step-up code."); + } + + [Fact] + public async Task StepUpVerify_ExceedsAuthRateLimit_ReturnsTooManyRequests() + { + // The auth limiter (5/min, partitioned by user) guards the verify endpoint and trips + // before the service-level max-attempts gate can ever be reached over HTTP, so the + // HTTP boundary's repeated-verify protection is the rate limiter, not max-attempts. + var pendingOperationId = SeedStepUpPendingOperation(); + var challenge = await IssueChallengeAsync(pendingOperationId); + + HttpStatusCode? rateLimited = null; + for (var attempt = 0; attempt < 6 && rateLimited is null; attempt++) + { + var response = await PostStepUpVerifyAsync(pendingOperationId, challenge.ChallengeId, "000000"); + if (response.StatusCode == HttpStatusCode.TooManyRequests) + rateLimited = response.StatusCode; + else + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + rateLimited.Should().Be(HttpStatusCode.TooManyRequests); + } + + // ── execute ────────────────────────────────────────────── + + [Fact] + public async Task Execute_WithoutStepUpSatisfied_IsNotExecuted() + { + var pendingOperationId = SeedStepUpPendingOperation(); + var confirmationToken = await ConfirmAsync(pendingOperationId); + + var response = await PostExecuteAsync(pendingOperationId, confirmationToken); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var execution = await response.Content.ReadFromJsonAsync(JsonOptions); + execution.Should().NotBeNull(); + execution!.Operation.Status.Should().Be(AgentOperationStatus.PendingConfirmation); + execution.Operation.PolicyReason.Should().Be("step_up_required"); + } + + [Fact] + public async Task Execute_AfterConfirmAndStepUp_Succeeds() + { + var pendingOperationId = SeedStepUpPendingOperation(); + var confirmationToken = await ConfirmAsync(pendingOperationId); + var challenge = await IssueChallengeAsync(pendingOperationId); + + var verifyResponse = await PostStepUpVerifyAsync(pendingOperationId, challenge.ChallengeId, _factory.Email.LastVerificationCode!); + verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var response = await PostExecuteAsync(pendingOperationId, confirmationToken); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var execution = await response.Content.ReadFromJsonAsync(JsonOptions); + execution.Should().NotBeNull(); + execution!.Operation.Status.Should().Be(AgentOperationStatus.Succeeded, "policy reason was: {0}", execution.Operation.PolicyReason); + + var keysResponse = await _client.GetAsync("/api/api-keys"); + keysResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var keys = await keysResponse.Content.ReadFromJsonAsync>(JsonOptions); + keys.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Execute_UnknownId_ReturnsNotFound() + { + var response = await PostExecuteAsync(Guid.NewGuid(), "x"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + body!.Error.Should().NotBeNullOrEmpty(); + } + + // ── helpers ────────────────────────────────────────────── + + private Guid SeedStepUpPendingOperation() + { + using var scope = _factory.Services.CreateScope(); + var catalog = scope.ServiceProvider.GetRequiredService(); + var store = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // ArgumentsJson is a jsonb column, so reading it back yields Postgres-normalized text + // (reordered keys, spaces after separators). The execute-time fingerprint is derived + // from that normalized text, so the seed must store args already in jsonb-canonical + // form for the fingerprint to match and confirmation to be consumable. + var canonicalArguments = NormalizeToJsonb(dbContext, CreateApiKeyArgumentsJson); + + var capability = catalog.GetCapability(AgentCapabilityIds.ApiKeysManage)!; + var pendingOperation = store.Create( + _userId, + capability, + CreateApiKeyOperationId, + canonicalArguments, + "Create API key", + $"{CreateApiKeyOperationId}:{canonicalArguments}", + AgentExecutionSurface.Chat); + + return pendingOperation.Id; + } + + private static string NormalizeToJsonb(OrbitDbContext dbContext, string json) + { + var connection = dbContext.Database.GetDbConnection(); + var wasClosed = connection.State != System.Data.ConnectionState.Open; + if (wasClosed) + connection.Open(); + + try + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT (@value)::jsonb::text"; + var parameter = command.CreateParameter(); + parameter.ParameterName = "@value"; + parameter.Value = json; + command.Parameters.Add(parameter); + return (string)command.ExecuteScalar()!; + } + finally + { + if (wasClosed) + connection.Close(); + } + } + + private async Task GrantProAccessAsync() + { + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var user = await dbContext.Users.SingleAsync(item => item.Id == _userId); + user.StartTrial(DateTime.UtcNow.AddDays(1)); + await dbContext.SaveChangesAsync(); + } + + private Task PostStepUpAsync(Guid pendingOperationId) + => _client.PostAsJsonAsync($"/api/ai/pending-operations/{pendingOperationId}/step-up", new { }); + + private Task PostStepUpVerifyAsync(Guid pendingOperationId, Guid challengeId, string code) + => _client.PostAsJsonAsync( + $"/api/ai/pending-operations/{pendingOperationId}/step-up/verify", + new { challengeId, code }); + + private Task PostExecuteAsync(Guid pendingOperationId, string confirmationToken) + => _client.PostAsJsonAsync( + $"/api/ai/pending-operations/{pendingOperationId}/execute", + new { confirmationToken }); + + private async Task ConfirmAsync(Guid pendingOperationId) + { + var response = await _client.PostAsync($"/api/ai/pending-operations/{pendingOperationId}/confirm", null); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var confirmation = await response.Content.ReadFromJsonAsync(JsonOptions); + return confirmation!.ConfirmationToken; + } + + private async Task IssueChallengeAsync(Guid pendingOperationId) + { + var response = await PostStepUpAsync(pendingOperationId); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var challenge = await response.Content.ReadFromJsonAsync(JsonOptions); + return challenge!; + } + + // ── DTOs ───────────────────────────────────────────────── + + private record ConfirmResponse(Guid PendingOperationId, string ConfirmationToken, DateTime ExpiresAtUtc); + private record StepUpChallengeResponse(Guid ChallengeId, Guid PendingOperationId, DateTime ExpiresAtUtc); + private record OperationResult(AgentOperationStatus Status, string? PolicyReason); + private record ExecuteResponse(OperationResult Operation); + private record ApiKeyListItem(Guid Id, string Name); + private record ErrorResponse(string Error); +} From 1ff45d2e0fc18ead06ff622202216c210abd718a Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 16:40:39 -0300 Subject: [PATCH 2/2] test(api): isolate step-up rate-limit test to its own user (#81) The auth rate-limit (5/min, partitioned by user) is shared across every step-up and verify call. StepUpVerify_ExceedsAuthRateLimit deliberately exhausts it, and as a sibling of the other pending-ops tests on the same class-wide account that bled 429s into them whenever they shared a clock-minute. Move it into a dedicated nested RateLimitTests class with its own IAsyncLifetime and its own registered account so the exhausted bucket is isolated. Shared seeding helpers become static and parameterized. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PendingOperationsControllerTests.cs | 115 ++++++++++++------ 1 file changed, 79 insertions(+), 36 deletions(-) diff --git a/tests/Orbit.IntegrationTests/PendingOperationsControllerTests.cs b/tests/Orbit.IntegrationTests/PendingOperationsControllerTests.cs index 6f5c2cae..171a44f2 100644 --- a/tests/Orbit.IntegrationTests/PendingOperationsControllerTests.cs +++ b/tests/Orbit.IntegrationTests/PendingOperationsControllerTests.cs @@ -43,7 +43,7 @@ public async Task InitializeAsync() // The api_keys.manage capability is plan-gated to Pro; an active trial grants Pro access // so policy evaluation reaches the step-up gate instead of denying on plan. - await GrantProAccessAsync(); + await GrantProAccessAsync(_factory, _userId); } public Task DisposeAsync() @@ -57,7 +57,7 @@ public Task DisposeAsync() [Fact] public async Task Confirm_PersistsConfirmationToken() { - var pendingOperationId = SeedStepUpPendingOperation(); + var pendingOperationId = SeedStepUpPendingOperation(_factory, _userId); var response = await _client.PostAsync($"/api/ai/pending-operations/{pendingOperationId}/confirm", null); @@ -91,7 +91,7 @@ public async Task Confirm_UnknownId_ReturnsNotFound() [Fact] public async Task StepUp_IssuesChallenge_AndEmailsCode() { - var pendingOperationId = SeedStepUpPendingOperation(); + var pendingOperationId = SeedStepUpPendingOperation(_factory, _userId); var response = await PostStepUpAsync(pendingOperationId); @@ -108,7 +108,7 @@ public async Task StepUp_IssuesChallenge_AndEmailsCode() [Fact] public async Task StepUp_SecondRequestWithinCooldown_ReturnsBadRequest() { - var pendingOperationId = SeedStepUpPendingOperation(); + var pendingOperationId = SeedStepUpPendingOperation(_factory, _userId); var first = await PostStepUpAsync(pendingOperationId); first.StatusCode.Should().Be(HttpStatusCode.OK); @@ -125,7 +125,7 @@ public async Task StepUp_SecondRequestWithinCooldown_ReturnsBadRequest() [Fact] public async Task StepUpVerify_ValidCode_Succeeds() { - var pendingOperationId = SeedStepUpPendingOperation(); + var pendingOperationId = SeedStepUpPendingOperation(_factory, _userId); var challenge = await IssueChallengeAsync(pendingOperationId); var response = await PostStepUpVerifyAsync(pendingOperationId, challenge.ChallengeId, _factory.Email.LastVerificationCode!); @@ -143,7 +143,7 @@ public async Task StepUpVerify_ValidCode_Succeeds() [Fact] public async Task StepUpVerify_InvalidCode_ReturnsBadRequest() { - var pendingOperationId = SeedStepUpPendingOperation(); + var pendingOperationId = SeedStepUpPendingOperation(_factory, _userId); var challenge = await IssueChallengeAsync(pendingOperationId); var response = await PostStepUpVerifyAsync(pendingOperationId, challenge.ChallengeId, "000000"); @@ -153,34 +153,12 @@ public async Task StepUpVerify_InvalidCode_ReturnsBadRequest() body!.Error.Should().Be("Invalid step-up code."); } - [Fact] - public async Task StepUpVerify_ExceedsAuthRateLimit_ReturnsTooManyRequests() - { - // The auth limiter (5/min, partitioned by user) guards the verify endpoint and trips - // before the service-level max-attempts gate can ever be reached over HTTP, so the - // HTTP boundary's repeated-verify protection is the rate limiter, not max-attempts. - var pendingOperationId = SeedStepUpPendingOperation(); - var challenge = await IssueChallengeAsync(pendingOperationId); - - HttpStatusCode? rateLimited = null; - for (var attempt = 0; attempt < 6 && rateLimited is null; attempt++) - { - var response = await PostStepUpVerifyAsync(pendingOperationId, challenge.ChallengeId, "000000"); - if (response.StatusCode == HttpStatusCode.TooManyRequests) - rateLimited = response.StatusCode; - else - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - rateLimited.Should().Be(HttpStatusCode.TooManyRequests); - } - // ── execute ────────────────────────────────────────────── [Fact] public async Task Execute_WithoutStepUpSatisfied_IsNotExecuted() { - var pendingOperationId = SeedStepUpPendingOperation(); + var pendingOperationId = SeedStepUpPendingOperation(_factory, _userId); var confirmationToken = await ConfirmAsync(pendingOperationId); var response = await PostExecuteAsync(pendingOperationId, confirmationToken); @@ -195,7 +173,7 @@ public async Task Execute_WithoutStepUpSatisfied_IsNotExecuted() [Fact] public async Task Execute_AfterConfirmAndStepUp_Succeeds() { - var pendingOperationId = SeedStepUpPendingOperation(); + var pendingOperationId = SeedStepUpPendingOperation(_factory, _userId); var confirmationToken = await ConfirmAsync(pendingOperationId); var challenge = await IssueChallengeAsync(pendingOperationId); @@ -227,9 +205,9 @@ public async Task Execute_UnknownId_ReturnsNotFound() // ── helpers ────────────────────────────────────────────── - private Guid SeedStepUpPendingOperation() + private static Guid SeedStepUpPendingOperation(IntegrationTestWebApplicationFactory factory, Guid userId) { - using var scope = _factory.Services.CreateScope(); + using var scope = factory.Services.CreateScope(); var catalog = scope.ServiceProvider.GetRequiredService(); var store = scope.ServiceProvider.GetRequiredService(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -242,7 +220,7 @@ private Guid SeedStepUpPendingOperation() var capability = catalog.GetCapability(AgentCapabilityIds.ApiKeysManage)!; var pendingOperation = store.Create( - _userId, + userId, capability, CreateApiKeyOperationId, canonicalArguments, @@ -277,11 +255,11 @@ private static string NormalizeToJsonb(OrbitDbContext dbContext, string json) } } - private async Task GrantProAccessAsync() + private static async Task GrantProAccessAsync(IntegrationTestWebApplicationFactory factory, Guid userId) { - using var scope = _factory.Services.CreateScope(); + using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var user = await dbContext.Users.SingleAsync(item => item.Id == _userId); + var user = await dbContext.Users.SingleAsync(item => item.Id == userId); user.StartTrial(DateTime.UtcNow.AddDays(1)); await dbContext.SaveChangesAsync(); } @@ -323,4 +301,69 @@ private record OperationResult(AgentOperationStatus Status, string? PolicyReason private record ExecuteResponse(OperationResult Operation); private record ApiKeyListItem(Guid Id, string Name); private record ErrorResponse(string Error); + + // The auth limiter (5/min, partitioned by user) is shared across every step-up and + // verify call. Exhausting it on the class-wide account would bleed 429s into the other + // pending-ops tests whenever they share a clock-minute, so this case gets its own user + // and its own exhausted bucket. + [Collection("Sequential")] + public sealed class RateLimitTests : IAsyncLifetime + { + private readonly IntegrationTestWebApplicationFactory _factory; + private readonly HttpClient _client; + private readonly string _email = $"pendingops-ratelimit-test-{Guid.NewGuid()}@integration.test"; + private Guid _userId; + private Guid _pendingOperationId; + private Guid _challengeId; + + public RateLimitTests(IntegrationTestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + IntegrationTestHelpers.RegisterTestAccount(_email, TestCode); + } + + public async Task InitializeAsync() + { + var login = await IntegrationTestHelpers.AuthenticateWithCodeAsync(_client, _email, TestCode, JsonOptions); + _userId = login.UserId; + await GrantProAccessAsync(_factory, _userId); + + _pendingOperationId = SeedStepUpPendingOperation(_factory, _userId); + + var challengeResponse = await _client.PostAsJsonAsync( + $"/api/ai/pending-operations/{_pendingOperationId}/step-up", + new { }); + challengeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var challenge = await challengeResponse.Content.ReadFromJsonAsync(JsonOptions); + _challengeId = challenge!.ChallengeId; + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task StepUpVerify_ExceedsAuthRateLimit_ReturnsTooManyRequests() + { + // The auth limiter trips before the service-level max-attempts gate can ever be + // reached over HTTP, so the HTTP boundary's repeated-verify protection is the rate + // limiter, not max-attempts. + HttpStatusCode? rateLimited = null; + for (var attempt = 0; attempt < 6 && rateLimited is null; attempt++) + { + var response = await _client.PostAsJsonAsync( + $"/api/ai/pending-operations/{_pendingOperationId}/step-up/verify", + new { challengeId = _challengeId, code = "000000" }); + if (response.StatusCode == HttpStatusCode.TooManyRequests) + rateLimited = response.StatusCode; + else + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + rateLimited.Should().Be(HttpStatusCode.TooManyRequests); + } + } }