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
- 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.
684 lines
18 KiB
Markdown
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.
|