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

684 lines
18 KiB
Markdown

# 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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```yaml
# 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](27-Step.md) to create example implementations.