Files
git.stella-ops.org/docs/router/26-Step.md
master 75f6942769
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Add integration tests for migration categories and execution
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations.
- Added tests for edge cases, including null, empty, and whitespace migration names.
- Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers.
- Included tests for migration execution, schema creation, and handling of pending release migrations.
- Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
2025-12-04 19:10:54 +02:00

18 KiB

Step 26: End-to-End Testing

Phase 7: Testing & Documentation Estimated Complexity: High Dependencies: All implementation steps


Overview

End-to-end testing validates the complete request flow from HTTP client through the gateway, transport layer, microservice, and back. Tests cover all handlers, authentication, rate limiting, streaming, and failure scenarios.


Goals

  1. Validate complete request/response flow
  2. Test all route handlers
  3. Verify authentication and authorization
  4. Test rate limiting behavior
  5. Validate streaming and large payloads
  6. Test failure scenarios and resilience

Test Infrastructure

namespace StellaOps.Router.Tests;

/// <summary>
/// End-to-end test fixture providing gateway and microservice hosts.
/// </summary>
public sealed class EndToEndTestFixture : IAsyncLifetime
{
    private IHost? _gatewayHost;
    private IHost? _microserviceHost;
    private InMemoryTransportHub? _transportHub;

    public HttpClient GatewayClient { get; private set; } = null!;
    public string GatewayBaseUrl { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        // Shared transport hub for InMemory testing
        _transportHub = new InMemoryTransportHub(
            NullLoggerFactory.Instance.CreateLogger<InMemoryTransportHub>());

        // Start gateway
        _gatewayHost = await CreateGatewayHostAsync();
        await _gatewayHost.StartAsync();

        GatewayBaseUrl = "http://localhost:5000";
        GatewayClient = new HttpClient { BaseAddress = new Uri(GatewayBaseUrl) };

        // Start test microservice
        _microserviceHost = await CreateMicroserviceHostAsync();
        await _microserviceHost.StartAsync();

        // Wait for connection
        await Task.Delay(500);
    }

    private async Task<IHost> CreateGatewayHostAsync()
    {
        return Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(web =>
            {
                web.UseUrls("http://localhost:5000");
                web.ConfigureServices((context, services) =>
                {
                    services.AddSingleton(_transportHub!);
                    services.AddStellaGateway(context.Configuration);
                    services.AddInMemoryTransport();

                    // Use in-memory rate limiter
                    services.AddSingleton<IRateLimiter, InMemoryRateLimiter>();

                    // Mock Authority
                    services.AddSingleton<IAuthorityClient, MockAuthorityClient>();
                });

                web.Configure(app =>
                {
                    app.UseRouting();
                    app.UseStellaGateway();
                    app.UseEndpoints(endpoints =>
                    {
                        endpoints.MapStellaRoutes();
                    });
                });
            })
            .Build();
    }

    private async Task<IHost> CreateMicroserviceHostAsync()
    {
        var host = StellaMicroserviceBuilder
            .Create("test-service")
            .ConfigureServices(services =>
            {
                services.AddSingleton(_transportHub!);
                services.AddScoped<TestEndpointHandler>();
            })
            .ConfigureTransport(t => t.Default = "InMemory")
            .ConfigureEndpoints(e =>
            {
                e.AutoDiscover = true;
                e.BasePath = "/api";
            })
            .Build();

        return (IHost)host;
    }

    public async Task DisposeAsync()
    {
        GatewayClient.Dispose();

        if (_microserviceHost != null)
        {
            await _microserviceHost.StopAsync();
            _microserviceHost.Dispose();
        }

        if (_gatewayHost != null)
        {
            await _gatewayHost.StopAsync();
            _gatewayHost.Dispose();
        }

        _transportHub?.Dispose();
    }
}

Test Endpoint Handler

namespace StellaOps.Router.Tests;

[StellaEndpoint(BasePath = "/test")]
public class TestEndpointHandler : EndpointHandler
{
    [StellaGet("echo")]
    public ResponsePayload Echo()
    {
        return Ok(new
        {
            method = Context.Method,
            path = Context.Path,
            query = Context.Query.ToDictionary(q => q.Key, q => q.Value.ToString()),
            headers = Context.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()),
            claims = Context.Claims
        });
    }

    [StellaPost("echo")]
    public async Task<ResponsePayload> EchoBody()
    {
        var body = Context.ReadBodyAsString();
        return Ok(new { body });
    }

    [StellaGet("items/{id}")]
    public ResponsePayload GetItem([FromPath] string id)
    {
        return Ok(new { id });
    }

    [StellaGet("slow")]
    public async Task<ResponsePayload> SlowEndpoint(CancellationToken cancellationToken)
    {
        await Task.Delay(5000, cancellationToken);
        return Ok(new { completed = true });
    }

    [StellaGet("error")]
    public ResponsePayload ThrowError()
    {
        throw new InvalidOperationException("Test error");
    }

    [StellaGet("status/{code}")]
    public ResponsePayload ReturnStatus([FromPath] int code)
    {
        return Response().WithStatus(code).WithJson(new { statusCode = code }).Build();
    }

    [StellaGet("protected")]
    [StellaAuth(RequiredClaims = new[] { "admin" })]
    public ResponsePayload ProtectedEndpoint()
    {
        return Ok(new { message = "Access granted" });
    }

    [StellaPost("upload")]
    public ResponsePayload HandleUpload()
    {
        var size = Context.ContentLength ?? Context.RawBody?.Length ?? 0;
        return Ok(new { bytesReceived = size });
    }

    [StellaGet("stream")]
    public ResponsePayload StreamResponse()
    {
        var data = new byte[1024 * 1024]; // 1MB
        Random.Shared.NextBytes(data);
        return Response()
            .WithBytes(data, "application/octet-stream")
            .Build();
    }
}

Basic Request/Response Tests

namespace StellaOps.Router.Tests;

public class BasicRequestResponseTests : IClassFixture<EndToEndTestFixture>
{
    private readonly EndToEndTestFixture _fixture;

    public BasicRequestResponseTests(EndToEndTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task Get_Echo_ReturnsRequestDetails()
    {
        // Arrange
        var client = _fixture.GatewayClient;

        // Act
        var response = await client.GetAsync("/api/test/echo");
        var content = await response.Content.ReadFromJsonAsync<EchoResponse>();

        // Assert
        Assert.True(response.IsSuccessStatusCode);
        Assert.Equal("GET", content?.Method);
        Assert.Equal("/api/test/echo", content?.Path);
    }

    [Fact]
    public async Task Post_Echo_ReturnsBody()
    {
        // Arrange
        var client = _fixture.GatewayClient;
        var body = new StringContent("{\"test\": true}", Encoding.UTF8, "application/json");

        // Act
        var response = await client.PostAsync("/api/test/echo", body);
        var content = await response.Content.ReadFromJsonAsync<EchoBodyResponse>();

        // Assert
        Assert.True(response.IsSuccessStatusCode);
        Assert.Contains("test", content?.Body);
    }

    [Fact]
    public async Task Get_WithPathParameter_ExtractsParameter()
    {
        // Arrange
        var client = _fixture.GatewayClient;

        // Act
        var response = await client.GetAsync("/api/test/items/12345");
        var content = await response.Content.ReadFromJsonAsync<ItemResponse>();

        // Assert
        Assert.True(response.IsSuccessStatusCode);
        Assert.Equal("12345", content?.Id);
    }

    [Fact]
    public async Task Get_NonExistentPath_Returns404()
    {
        // Arrange
        var client = _fixture.GatewayClient;

        // Act
        var response = await client.GetAsync("/api/nonexistent");

        // Assert
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    private record EchoResponse(
        string Method,
        string Path,
        Dictionary<string, string> Query,
        Dictionary<string, string> Claims);

    private record EchoBodyResponse(string Body);
    private record ItemResponse(string Id);
}

Authentication Tests

namespace StellaOps.Router.Tests;

public class AuthenticationTests : IClassFixture<EndToEndTestFixture>
{
    private readonly EndToEndTestFixture _fixture;

    public AuthenticationTests(EndToEndTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task Protected_WithoutToken_Returns401()
    {
        // Arrange
        var client = _fixture.GatewayClient;

        // Act
        var response = await client.GetAsync("/api/test/protected");

        // Assert
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task Protected_WithValidToken_Returns200()
    {
        // Arrange
        var client = _fixture.GatewayClient;
        var token = CreateTestToken(new Dictionary<string, string> { ["admin"] = "true" });
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

        // Act
        var response = await client.GetAsync("/api/test/protected");

        // Assert
        Assert.True(response.IsSuccessStatusCode);
    }

    [Fact]
    public async Task Protected_WithInvalidToken_Returns401()
    {
        // Arrange
        var client = _fixture.GatewayClient;
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token");

        // Act
        var response = await client.GetAsync("/api/test/protected");

        // Assert
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task Protected_WithMissingClaim_Returns403()
    {
        // Arrange
        var client = _fixture.GatewayClient;
        var token = CreateTestToken(new Dictionary<string, string> { ["user"] = "true" }); // No admin claim
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

        // Act
        var response = await client.GetAsync("/api/test/protected");

        // Assert
        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
    }

    private string CreateTestToken(Dictionary<string, string> claims)
    {
        // Create a test JWT (would use test key in real implementation)
        var handler = new JwtSecurityTokenHandler();
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-key-for-testing-only-12345"));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claimsList = claims.Select(c => new Claim(c.Key, c.Value)).ToList();
        claimsList.Add(new Claim("sub", "test-user"));

        var token = new JwtSecurityToken(
            issuer: "test",
            audience: "test",
            claims: claimsList,
            expires: DateTime.UtcNow.AddHours(1),
            signingCredentials: creds);

        return handler.WriteToken(token);
    }
}

Rate Limiting Tests

namespace StellaOps.Router.Tests;

public class RateLimitingTests : IClassFixture<EndToEndTestFixture>
{
    private readonly EndToEndTestFixture _fixture;

    public RateLimitingTests(EndToEndTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task RateLimit_ExceedingLimit_Returns429()
    {
        // Arrange
        var client = _fixture.GatewayClient;
        var tasks = new List<Task<HttpResponseMessage>>();

        // Act - Send 100 requests quickly
        for (int i = 0; i < 100; i++)
        {
            tasks.Add(client.GetAsync("/api/test/echo"));
        }

        var responses = await Task.WhenAll(tasks);

        // Assert - Some should be rate limited
        var rateLimited = responses.Count(r => r.StatusCode == HttpStatusCode.TooManyRequests);
        Assert.True(rateLimited > 0, "Expected some requests to be rate limited");
    }

    [Fact]
    public async Task RateLimit_Headers_ArePresent()
    {
        // Arrange
        var client = _fixture.GatewayClient;

        // Act
        var response = await client.GetAsync("/api/test/echo");

        // Assert
        Assert.True(response.Headers.Contains("X-RateLimit-Limit"));
        Assert.True(response.Headers.Contains("X-RateLimit-Remaining"));
    }

    [Fact]
    public async Task RateLimit_PerUser_IsolatesUsers()
    {
        // Arrange
        var client1 = new HttpClient { BaseAddress = new Uri(_fixture.GatewayBaseUrl) };
        var client2 = new HttpClient { BaseAddress = new Uri(_fixture.GatewayBaseUrl) };

        client1.DefaultRequestHeaders.Add("X-API-Key", "user1-key");
        client2.DefaultRequestHeaders.Add("X-API-Key", "user2-key");

        // Act - Exhaust rate limit for user1
        for (int i = 0; i < 50; i++)
        {
            await client1.GetAsync("/api/test/echo");
        }

        // User2 should still have quota
        var response = await client2.GetAsync("/api/test/echo");

        // Assert
        Assert.True(response.IsSuccessStatusCode);
    }
}

Timeout and Cancellation Tests

namespace StellaOps.Router.Tests;

public class TimeoutAndCancellationTests : IClassFixture<EndToEndTestFixture>
{
    private readonly EndToEndTestFixture _fixture;

    public TimeoutAndCancellationTests(EndToEndTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task Slow_Request_TimesOut()
    {
        // Arrange
        var client = new HttpClient
        {
            BaseAddress = new Uri(_fixture.GatewayBaseUrl),
            Timeout = TimeSpan.FromSeconds(1)
        };

        // Act & Assert
        await Assert.ThrowsAsync<TaskCanceledException>(
            () => client.GetAsync("/api/test/slow"));
    }

    [Fact]
    public async Task Cancelled_Request_PropagatesCancellation()
    {
        // Arrange
        var client = _fixture.GatewayClient;
        using var cts = new CancellationTokenSource();

        // Act
        var task = client.GetAsync("/api/test/slow", cts.Token);
        await Task.Delay(100);
        cts.Cancel();

        // Assert
        await Assert.ThrowsAsync<TaskCanceledException>(() => task);
    }
}

Streaming and Large Payload Tests

namespace StellaOps.Router.Tests;

public class StreamingTests : IClassFixture<EndToEndTestFixture>
{
    private readonly EndToEndTestFixture _fixture;

    public StreamingTests(EndToEndTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task LargeUpload_Succeeds()
    {
        // Arrange
        var client = _fixture.GatewayClient;
        var data = new byte[1024 * 1024]; // 1MB
        Random.Shared.NextBytes(data);
        var content = new ByteArrayContent(data);
        content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");

        // Act
        var response = await client.PostAsync("/api/test/upload", content);
        var result = await response.Content.ReadFromJsonAsync<UploadResponse>();

        // Assert
        Assert.True(response.IsSuccessStatusCode);
        Assert.Equal(data.Length, result?.BytesReceived);
    }

    [Fact]
    public async Task LargeDownload_Succeeds()
    {
        // Arrange
        var client = _fixture.GatewayClient;

        // Act
        var response = await client.GetAsync("/api/test/stream");
        var data = await response.Content.ReadAsByteArrayAsync();

        // Assert
        Assert.True(response.IsSuccessStatusCode);
        Assert.Equal(1024 * 1024, data.Length);
    }

    private record UploadResponse(long BytesReceived);
}

Error Handling Tests

namespace StellaOps.Router.Tests;

public class ErrorHandlingTests : IClassFixture<EndToEndTestFixture>
{
    private readonly EndToEndTestFixture _fixture;

    public ErrorHandlingTests(EndToEndTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task Handler_Exception_Returns500()
    {
        // Arrange
        var client = _fixture.GatewayClient;

        // Act
        var response = await client.GetAsync("/api/test/error");

        // Assert
        Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
    }

    [Fact]
    public async Task Custom_StatusCode_IsPreserved()
    {
        // Arrange
        var client = _fixture.GatewayClient;

        // Act
        var response = await client.GetAsync("/api/test/status/418");

        // Assert
        Assert.Equal((HttpStatusCode)418, response.StatusCode);
    }

    [Fact]
    public async Task Error_Response_HasCorrectFormat()
    {
        // Arrange
        var client = _fixture.GatewayClient;

        // Act
        var response = await client.GetAsync("/api/nonexistent");
        var content = await response.Content.ReadFromJsonAsync<ErrorResponse>();

        // Assert
        Assert.NotNull(content?.Error);
    }

    private record ErrorResponse(string Error);
}

YAML Configuration

# Test configuration
Router:
  Transports:
    - Type: InMemory
      Enabled: true

RateLimiting:
  Enabled: true
  DefaultTier: free
  Tiers:
    free:
      RequestsPerMinute: 60
    authenticated:
      RequestsPerMinute: 600

Authentication:
  Enabled: true
  AllowAnonymous: false
  TestMode: true

Deliverables

  1. StellaOps.Router.Tests/EndToEndTestFixture.cs
  2. StellaOps.Router.Tests/TestEndpointHandler.cs
  3. StellaOps.Router.Tests/BasicRequestResponseTests.cs
  4. StellaOps.Router.Tests/AuthenticationTests.cs
  5. StellaOps.Router.Tests/RateLimitingTests.cs
  6. StellaOps.Router.Tests/TimeoutAndCancellationTests.cs
  7. StellaOps.Router.Tests/StreamingTests.cs
  8. StellaOps.Router.Tests/ErrorHandlingTests.cs
  9. Mock implementations for Authority, Rate Limiter
  10. CI integration configuration

Next Step

Proceed to Step 27: Reference Example & Migration Skeleton to create example implementations.