- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism. - Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions. - Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests. - Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
10 KiB
WebService Test Discipline
This document defines the testing discipline for StellaOps WebService projects. All web services must follow these patterns to ensure consistent test coverage, contract stability, telemetry verification, and security hardening.
Overview
WebService tests use WebServiceFixture<TProgram> from StellaOps.TestKit and WebApplicationFactory<TProgram> from Microsoft.AspNetCore.Mvc.Testing. Tests are organized into four categories:
- Contract Tests — OpenAPI schema stability
- OTel Trace Tests — Telemetry verification
- Negative Tests — Error handling validation
- Auth/AuthZ Tests — Security boundary enforcement
1. Test Infrastructure
WebServiceFixture Pattern
using StellaOps.TestKit.Fixtures;
public class ScannerWebServiceTests : WebServiceTestBase<ScannerProgram>
{
public ScannerWebServiceTests() : base(new WebServiceFixture<ScannerProgram>())
{
}
// Tests inherit shared fixture setup
}
Fixture Configuration
Each web service should have a dedicated fixture class that configures test-specific settings:
public sealed class ScannerTestFixture : WebServiceFixture<ScannerProgram>
{
protected override void ConfigureTestServices(IServiceCollection services)
{
// Replace external dependencies with test doubles
services.AddSingleton<IStorageClient, InMemoryStorageClient>();
services.AddSingleton<IQueueClient, InMemoryQueueClient>();
}
protected override void ConfigureTestConfiguration(IDictionary<string, string?> config)
{
config["scanner:storage:driver"] = "inmemory";
config["scanner:events:enabled"] = "false";
}
}
2. Contract Tests
Contract tests ensure OpenAPI schema stability and detect breaking changes.
Pattern
[Fact]
[Trait("Lane", "Contract")]
public async Task OpenApi_Schema_MatchesSnapshot()
{
// Arrange
using var client = Fixture.CreateClient();
// Act
var response = await client.GetAsync("/swagger/v1/swagger.json");
var schema = await response.Content.ReadAsStringAsync();
// Assert
await ContractTestHelper.AssertSchemaMatchesSnapshot(schema, "scanner-v1");
}
[Fact]
[Trait("Lane", "Contract")]
public async Task Api_Response_MatchesContract()
{
// Arrange
using var client = Fixture.CreateClient();
var request = new ScanRequest { /* test data */ };
// Act
var response = await client.PostAsJsonAsync("/api/v1/scans", request);
var result = await response.Content.ReadFromJsonAsync<ScanResponse>();
// Assert
ContractTestHelper.AssertResponseMatchesSchema(result, "ScanResponse");
}
Snapshot Management
- Snapshots stored in
Snapshots/directory relative to test project - Schema format:
<service>-<version>.json - Update snapshots intentionally when breaking changes are approved
3. OTel Trace Tests
OTel tests verify that telemetry spans are emitted correctly with required tags.
Pattern
[Fact]
[Trait("Lane", "Integration")]
public async Task ScanEndpoint_EmitsOtelTrace()
{
// Arrange
using var otelCapture = Fixture.CaptureOtelTraces();
using var client = Fixture.CreateClient();
var request = new ScanRequest { ImageRef = "nginx:1.25" };
// Act
await client.PostAsJsonAsync("/api/v1/scans", request);
// Assert
otelCapture.AssertHasSpan("scanner.scan");
otelCapture.AssertHasTag("scanner.scan", "scan.image_ref", "nginx:1.25");
otelCapture.AssertHasTag("scanner.scan", "tenant.id", ExpectedTenantId);
}
Required Tags
All WebService endpoints must emit these tags:
| Tag | Description | Example |
|---|---|---|
tenant.id |
Tenant identifier | tenant-a |
request.id |
Correlation ID | req-abc123 |
http.route |
Endpoint route | /api/v1/scans |
http.status_code |
Response code | 200 |
Service-specific tags are documented in each module's architecture doc.
4. Negative Tests
Negative tests verify proper error handling for invalid inputs.
Pattern
[Fact]
[Trait("Lane", "Security")]
public async Task MalformedContentType_Returns415()
{
// Arrange
using var client = Fixture.CreateClient();
var content = new StringContent("{}", Encoding.UTF8, "text/plain");
// Act
var response = await client.PostAsync("/api/v1/scans", content);
// Assert
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
}
[Fact]
[Trait("Lane", "Security")]
public async Task OversizedPayload_Returns413()
{
// Arrange
using var client = Fixture.CreateClient();
var payload = new string('x', 10_000_001); // Exceeds 10MB limit
var content = new StringContent(payload, Encoding.UTF8, "application/json");
// Act
var response = await client.PostAsync("/api/v1/scans", content);
// Assert
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
}
[Fact]
[Trait("Lane", "Unit")]
public async Task MethodMismatch_Returns405()
{
// Arrange
using var client = Fixture.CreateClient();
// Act (POST endpoint, but using GET)
var response = await client.GetAsync("/api/v1/scans");
// Assert
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
}
Required Coverage
| Negative Case | Expected Status | Test Trait |
|---|---|---|
| Malformed content type | 415 | Security |
| Oversized payload | 413 | Security |
| Method mismatch | 405 | Unit |
| Missing required field | 400 | Unit |
| Invalid field value | 400 | Unit |
| Unknown route | 404 | Unit |
5. Auth/AuthZ Tests
Auth tests verify security boundaries and tenant isolation.
Pattern
[Fact]
[Trait("Lane", "Security")]
public async Task AnonymousRequest_Returns401()
{
// Arrange
using var client = Fixture.CreateClient(); // No auth
// Act
var response = await client.GetAsync("/api/v1/scans");
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
[Trait("Lane", "Security")]
public async Task ExpiredToken_Returns401()
{
// Arrange
using var client = Fixture.CreateAuthenticatedClient(tokenExpired: true);
// Act
var response = await client.GetAsync("/api/v1/scans");
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
[Trait("Lane", "Security")]
public async Task TenantIsolation_CannotAccessOtherTenantData()
{
// Arrange
using var tenantAClient = Fixture.CreateTenantClient("tenant-a");
using var tenantBClient = Fixture.CreateTenantClient("tenant-b");
// Create scan as tenant A
var scanResponse = await tenantAClient.PostAsJsonAsync("/api/v1/scans", new ScanRequest { /* */ });
var scan = await scanResponse.Content.ReadFromJsonAsync<ScanResponse>();
// Act: Try to access as tenant B
var response = await tenantBClient.GetAsync($"/api/v1/scans/{scan!.Id}");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); // Tenant isolation
}
Required Coverage
| Auth Case | Expected Behavior | Test Trait |
|---|---|---|
| No token | 401 Unauthorized | Security |
| Expired token | 401 Unauthorized | Security |
| Invalid signature | 401 Unauthorized | Security |
| Wrong audience | 401 Unauthorized | Security |
| Missing scope | 403 Forbidden | Security |
| Cross-tenant access | 404 Not Found or 403 Forbidden | Security |
6. Test Organization
Directory Structure
src/<Module>/__Tests/StellaOps.<Module>.WebService.Tests/
├── StellaOps.<Module>.WebService.Tests.csproj
├── <Module>ApplicationFactory.cs # WebApplicationFactory implementation
├── <Module>TestFixture.cs # Shared test fixture
├── Contract/
│ └── OpenApiSchemaTests.cs
├── Telemetry/
│ └── OtelTraceTests.cs
├── Negative/
│ ├── ContentTypeTests.cs
│ ├── PayloadLimitTests.cs
│ └── MethodMismatchTests.cs
├── Auth/
│ ├── AuthenticationTests.cs
│ ├── AuthorizationTests.cs
│ └── TenantIsolationTests.cs
└── Snapshots/
└── <module>-v1.json # OpenAPI schema snapshot
Test Trait Assignment
| Category | Trait | CI Lane | PR-Gating |
|---|---|---|---|
| Contract | [Trait("Lane", "Contract")] |
Contract | Yes |
| OTel | [Trait("Lane", "Integration")] |
Integration | Yes |
| Negative (security) | [Trait("Lane", "Security")] |
Security | Yes |
| Negative (validation) | [Trait("Lane", "Unit")] |
Unit | Yes |
| Auth/AuthZ | [Trait("Lane", "Security")] |
Security | Yes |
7. CI Integration
WebService tests run in the appropriate CI lanes:
# .gitea/workflows/test-lanes.yml
jobs:
contract-tests:
steps:
- run: ./scripts/test-lane.sh Contract
security-tests:
steps:
- run: ./scripts/test-lane.sh Security
integration-tests:
steps:
- run: ./scripts/test-lane.sh Integration
All lanes are PR-gating. Failed tests block merge.
8. Rollout Checklist
When adding WebService tests to a new module:
- Create
<Module>ApplicationFactoryextendingWebApplicationFactory<TProgram> - Create
<Module>TestFixtureextendingWebServiceFixture<TProgram>if needed - Add contract tests with OpenAPI schema snapshot
- Add OTel trace tests for key endpoints
- Add negative tests (content type, payload, method)
- Add auth/authz tests (anonymous, expired, tenant isolation)
- Verify all tests have appropriate
[Trait("Lane", "...")]attributes - Run locally:
dotnet test --filter "Lane=Contract|Lane=Security|Lane=Integration" - Verify CI passes on PR
References
- WebServiceFixture Implementation
- ContractTestHelper Implementation
- WebServiceTestBase Implementation
- Test Lanes CI Workflow
- CI Lane Filters Documentation
Last updated: 2025-06-30 · Sprint 5100.0007.0006