Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class ScansEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubmitScanReturnsAcceptedAndStatusRetrievable()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor { Reference = "ghcr.io/demo/app:1.0.0" },
|
||||
Force = false
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/scans", request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId));
|
||||
Assert.Equal("Pending", payload.Status);
|
||||
Assert.True(payload.Created);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.Location));
|
||||
|
||||
var statusResponse = await client.GetAsync(payload.Location);
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
|
||||
var status = await statusResponse.Content.ReadFromJsonAsync<ScanStatusResponse>();
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(payload.ScanId, status!.ScanId);
|
||||
Assert.Equal("Pending", status.Status);
|
||||
Assert.Equal("ghcr.io/demo/app:1.0.0", status.Image.Reference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitScanIsDeterministicForIdenticalPayloads()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor { Reference = "registry.example.com/acme/app:latest" },
|
||||
Force = false,
|
||||
ClientRequestId = "client-123",
|
||||
Metadata = new Dictionary<string, string> { ["origin"] = "unit-test" }
|
||||
};
|
||||
|
||||
var first = await client.PostAsJsonAsync("/api/v1/scans", request);
|
||||
var firstPayload = await first.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
|
||||
var second = await client.PostAsJsonAsync("/api/v1/scans", request);
|
||||
var secondPayload = await second.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
|
||||
Assert.NotNull(firstPayload);
|
||||
Assert.NotNull(secondPayload);
|
||||
Assert.Equal(firstPayload!.ScanId, secondPayload!.ScanId);
|
||||
Assert.True(firstPayload.Created);
|
||||
Assert.False(secondPayload.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitScanValidatesImageDescriptor()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new
|
||||
{
|
||||
image = new { reference = "", digest = "" }
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/scans", request);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitScanPropagatesRequestAbortedToken()
|
||||
{
|
||||
RecordingCoordinator coordinator = null!;
|
||||
using var factory = new ScannerApplicationFactory(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
}, services =>
|
||||
{
|
||||
services.AddSingleton<IScanCoordinator>(sp =>
|
||||
{
|
||||
coordinator = new RecordingCoordinator(
|
||||
sp.GetRequiredService<IHttpContextAccessor>(),
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
sp.GetRequiredService<IScanProgressPublisher>());
|
||||
return coordinator;
|
||||
});
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var request = new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor { Reference = "example.com/demo:1.0" }
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/scans", request, cts.Token);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
Assert.NotNull(coordinator);
|
||||
Assert.True(coordinator.TokenMatched);
|
||||
Assert.True(coordinator.LastToken.CanBeCanceled);
|
||||
}
|
||||
|
||||
private sealed class RecordingCoordinator : IScanCoordinator
|
||||
{
|
||||
private readonly IHttpContextAccessor accessor;
|
||||
private readonly InMemoryScanCoordinator inner;
|
||||
|
||||
public RecordingCoordinator(IHttpContextAccessor accessor, TimeProvider timeProvider, IScanProgressPublisher publisher)
|
||||
{
|
||||
this.accessor = accessor;
|
||||
inner = new InMemoryScanCoordinator(timeProvider, publisher);
|
||||
}
|
||||
|
||||
public CancellationToken LastToken { get; private set; }
|
||||
|
||||
public bool TokenMatched { get; private set; }
|
||||
|
||||
public async ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
|
||||
{
|
||||
LastToken = cancellationToken;
|
||||
TokenMatched = accessor.HttpContext?.RequestAborted.Equals(cancellationToken) ?? false;
|
||||
return await inner.SubmitAsync(submission, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
|
||||
=> inner.GetAsync(scanId, cancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProgressStreamReturnsInitialPendingEvent()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor { Reference = "ghcr.io/demo/app:2.0.0" }
|
||||
};
|
||||
|
||||
var submit = await client.PostAsJsonAsync("/api/v1/scans", request);
|
||||
var submitPayload = await submit.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(submitPayload);
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/scans/{submitPayload!.ScanId}/events?format=jsonl", HttpCompletionOption.ResponseHeadersRead);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/x-ndjson", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
var line = await reader.ReadLineAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(line));
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<ProgressEnvelope>(line!, SerializerOptions);
|
||||
Assert.NotNull(envelope);
|
||||
Assert.Equal(submitPayload.ScanId, envelope!.ScanId);
|
||||
Assert.Equal("Pending", envelope.State);
|
||||
Assert.Equal(1, envelope.Sequence);
|
||||
Assert.NotEqual(default, envelope.Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProgressStreamYieldsSubsequentEvents()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor { Reference = "registry.example.com/acme/app:stream" }
|
||||
};
|
||||
|
||||
var submit = await client.PostAsJsonAsync("/api/v1/scans", request);
|
||||
var submitPayload = await submit.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(submitPayload);
|
||||
|
||||
var publisher = factory.Services.GetRequiredService<IScanProgressPublisher>();
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/scans/{submitPayload!.ScanId}/events?format=jsonl", HttpCompletionOption.ResponseHeadersRead);
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var firstLine = await reader.ReadLineAsync();
|
||||
Assert.NotNull(firstLine);
|
||||
var firstEnvelope = JsonSerializer.Deserialize<ProgressEnvelope>(firstLine!, SerializerOptions);
|
||||
Assert.NotNull(firstEnvelope);
|
||||
Assert.Equal("Pending", firstEnvelope!.State);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(50);
|
||||
publisher.Publish(new ScanId(submitPayload.ScanId), "Running", "worker-started", new Dictionary<string, object?>
|
||||
{
|
||||
["stage"] = "download"
|
||||
});
|
||||
});
|
||||
|
||||
ProgressEnvelope? envelope = null;
|
||||
string? line;
|
||||
do
|
||||
{
|
||||
line = await reader.ReadLineAsync();
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
envelope = JsonSerializer.Deserialize<ProgressEnvelope>(line, SerializerOptions);
|
||||
}
|
||||
while (envelope is not null && envelope.State == "Pending");
|
||||
|
||||
Assert.NotNull(envelope);
|
||||
Assert.Equal("Running", envelope!.State);
|
||||
Assert.True(envelope.Sequence >= 2);
|
||||
Assert.Contains(envelope.Data.Keys, key => key == "stage");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProgressStreamSupportsServerSentEvents()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor { Reference = "ghcr.io/demo/app:3.0.0" }
|
||||
};
|
||||
|
||||
var submit = await client.PostAsJsonAsync("/api/v1/scans", request);
|
||||
var submitPayload = await submit.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(submitPayload);
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/scans/{submitPayload!.ScanId}/events", HttpCompletionOption.ResponseHeadersRead);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var idLine = await reader.ReadLineAsync();
|
||||
var eventLine = await reader.ReadLineAsync();
|
||||
var dataLine = await reader.ReadLineAsync();
|
||||
var separator = await reader.ReadLineAsync();
|
||||
|
||||
Assert.Equal("id: 1", idLine);
|
||||
Assert.Equal("event: pending", eventLine);
|
||||
Assert.NotNull(dataLine);
|
||||
Assert.StartsWith("data: ", dataLine, StringComparison.Ordinal);
|
||||
Assert.Equal(string.Empty, separator);
|
||||
|
||||
var json = dataLine!["data: ".Length..];
|
||||
var envelope = JsonSerializer.Deserialize<ProgressEnvelope>(json, SerializerOptions);
|
||||
Assert.NotNull(envelope);
|
||||
Assert.Equal(submitPayload.ScanId, envelope!.ScanId);
|
||||
Assert.Equal("Pending", envelope.State);
|
||||
Assert.Equal(1, envelope.Sequence);
|
||||
Assert.True(envelope.Timestamp.UtcDateTime <= DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProgressStreamDataKeysAreSortedDeterministically()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor { Reference = "ghcr.io/demo/app:sorted" }
|
||||
};
|
||||
|
||||
var submit = await client.PostAsJsonAsync("/api/v1/scans", request);
|
||||
var submitPayload = await submit.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(submitPayload);
|
||||
|
||||
var publisher = factory.Services.GetRequiredService<IScanProgressPublisher>();
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/scans/{submitPayload!.ScanId}/events?format=jsonl", HttpCompletionOption.ResponseHeadersRead);
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
// Drain the initial pending event.
|
||||
_ = await reader.ReadLineAsync();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(25);
|
||||
publisher.Publish(
|
||||
new ScanId(submitPayload.ScanId),
|
||||
"Running",
|
||||
"stage-change",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["zeta"] = 1,
|
||||
["alpha"] = 2,
|
||||
["Beta"] = 3
|
||||
});
|
||||
});
|
||||
|
||||
string? line;
|
||||
JsonDocument? document = null;
|
||||
while ((line = await reader.ReadLineAsync()) is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = JsonDocument.Parse(line);
|
||||
if (parsed.RootElement.TryGetProperty("state", out var state) &&
|
||||
string.Equals(state.GetString(), "Running", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
document = parsed;
|
||||
break;
|
||||
}
|
||||
|
||||
parsed.Dispose();
|
||||
}
|
||||
|
||||
Assert.NotNull(document);
|
||||
using (document)
|
||||
{
|
||||
var data = document!.RootElement.GetProperty("data");
|
||||
var names = data.EnumerateObject().Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { "alpha", "Beta", "zeta" }, names);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private sealed record ProgressEnvelope(
|
||||
string ScanId,
|
||||
int Sequence,
|
||||
string State,
|
||||
string? Message,
|
||||
DateTimeOffset Timestamp,
|
||||
string CorrelationId,
|
||||
Dictionary<string, JsonElement> Data);
|
||||
}
|
||||
Reference in New Issue
Block a user