# 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` from `StellaOps.TestKit` and `WebApplicationFactory` from `Microsoft.AspNetCore.Mvc.Testing`. Tests are organized into four categories: 1. **Contract Tests** — OpenAPI schema stability 2. **OTel Trace Tests** — Telemetry verification 3. **Negative Tests** — Error handling validation 4. **Auth/AuthZ Tests** — Security boundary enforcement --- ## 1. Test Infrastructure ### WebServiceFixture Pattern ```csharp using StellaOps.TestKit.Fixtures; public class ScannerWebServiceTests : WebServiceTestBase { public ScannerWebServiceTests() : base(new WebServiceFixture()) { } // Tests inherit shared fixture setup } ``` ### Fixture Configuration Each web service should have a dedicated fixture class that configures test-specific settings: ```csharp public sealed class ScannerTestFixture : WebServiceFixture { protected override void ConfigureTestServices(IServiceCollection services) { // Replace external dependencies with test doubles services.AddSingleton(); services.AddSingleton(); } protected override void ConfigureTestConfiguration(IDictionary 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 ```csharp [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(); // Assert ContractTestHelper.AssertResponseMatchesSchema(result, "ScanResponse"); } ``` ### Snapshot Management - Snapshots stored in `Snapshots/` directory relative to test project - Schema format: `-.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 ```csharp [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 ```csharp [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 ```csharp [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(); // 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//__Tests/StellaOps..WebService.Tests/ ├── StellaOps..WebService.Tests.csproj ├── ApplicationFactory.cs # WebApplicationFactory implementation ├── 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/ └── -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: ```yaml # .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 `ApplicationFactory` extending `WebApplicationFactory` - [ ] Create `TestFixture` extending `WebServiceFixture` 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](../../src/__Libraries/StellaOps.TestKit/Fixtures/WebServiceFixture.cs) - [ContractTestHelper Implementation](../../src/__Libraries/StellaOps.TestKit/Fixtures/ContractTestHelper.cs) - [WebServiceTestBase Implementation](../../src/__Libraries/StellaOps.TestKit/Templates/WebServiceTestBase.cs) - [Test Lanes CI Workflow](../../.gitea/workflows/test-lanes.yml) - [CI Lane Filters Documentation](./ci-lane-filters.md) --- *Last updated: 2025-06-30 · Sprint 5100.0007.0006*