206 lines
9.3 KiB
C#
206 lines
9.3 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
|
|
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
|
|
|
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
|
|
|
|
/// <summary>
|
|
/// The explicit per-<c>InfrastructureObjectKind</c> override field catalog
|
|
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H3):
|
|
/// <c>ManualOverride</c> must never accept an arbitrary field name or unconstrained
|
|
/// JSON.
|
|
/// </summary>
|
|
[Collection(PostgresTestGroup.Name)]
|
|
public sealed class ManualOverrideCatalogTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
|
{
|
|
private async Task<(HttpClient Client, Guid ServiceId)> AuthenticatedClientWithServiceAsync()
|
|
{
|
|
var client = Factory.CreateClient();
|
|
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
|
|
|
await SetupHelper.PostWithCsrfAsync(
|
|
client, "/api/v1/infrastructure/inventory/snapshots", MinimalSnapshot($"hash-{Guid.NewGuid():N}"));
|
|
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
|
"/api/v1/infrastructure/services?includeHidden=true");
|
|
var service = Assert.Single(services!.Items);
|
|
return (client, service.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_catalog_endpoint_lists_exactly_the_fields_the_UI_may_offer_for_a_service()
|
|
{
|
|
var client = Factory.CreateClient();
|
|
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
|
|
|
var catalog = await client.GetFromJsonAsync<ManualOverrideFieldCatalogResponse>(
|
|
"/api/v1/infrastructure/overrides/catalog?targetKind=Service");
|
|
|
|
Assert.NotNull(catalog);
|
|
Assert.Equal("Service", catalog.TargetKind);
|
|
Assert.Equal(
|
|
new[] { "detectedName", "runtimeKind", "runtimeStatus", "imageReference", "health", "confidence" },
|
|
catalog.Fields.Select(field => field.FieldName));
|
|
var health = Assert.Single(catalog.Fields, field => field.FieldName == "health");
|
|
Assert.Equal("Enum", health.ValueKind);
|
|
Assert.Contains("Healthy", health.AllowedEnumValues!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unknown_field_name_is_rejected()
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
|
|
var response = await SetupHelper.PostWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
|
|
new SetManualOverrideRequest("arbitraryNewField", JsonSerializer.Deserialize<JsonElement>("\"waarde\""), "Poging."));
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_non_string_JSON_value_is_rejected_even_for_a_text_field()
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
|
|
var response = await SetupHelper.PostWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
|
|
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("12345"), "Verkeerd type."));
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_object_JSON_value_is_rejected()
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
|
|
var response = await SetupHelper.PostWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
|
|
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("{\"nested\":true}"), "Verkeerd type."));
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_oversized_JSON_value_is_rejected()
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
var huge = new string('a', 5000);
|
|
|
|
var response = await SetupHelper.PostWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
|
|
new SetManualOverrideRequest("detectedName", JsonSerializer.SerializeToElement(huge), "Te groot."));
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_string_value_longer_than_the_field_specific_maximum_is_rejected()
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
// Under the 4000-byte envelope cap but over runtimeKind's own 100-character
|
|
// field-specific bound — proves both limits are enforced independently.
|
|
var tooLongForField = new string('a', 200);
|
|
|
|
var response = await SetupHelper.PostWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
|
|
new SetManualOverrideRequest("runtimeKind", JsonSerializer.SerializeToElement(tooLongForField), "Te lang voor dit veld."));
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_invalid_enum_value_for_health_is_rejected()
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
|
|
var response = await SetupHelper.PostWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
|
|
new SetManualOverrideRequest("health", JsonSerializer.Deserialize<JsonElement>("\"NietBestaand\""), "Ongeldige status."));
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Unknown")]
|
|
[InlineData("Healthy")]
|
|
[InlineData("Degraded")]
|
|
[InlineData("Unhealthy")]
|
|
[InlineData("Stopped")]
|
|
public async Task Every_real_health_enum_value_is_accepted(string health)
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
|
|
var response = await SetupHelper.PostWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
|
|
new SetManualOverrideRequest("health", JsonSerializer.SerializeToElement(health), "Geldige status."));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_invalid_confidence_enum_value_is_rejected()
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
|
|
var response = await SetupHelper.PostWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
|
|
new SetManualOverrideRequest("confidence", JsonSerializer.Deserialize<JsonElement>("\"Zeker\""), "Ongeldig."));
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Malformed_JSON_in_the_raw_request_never_produces_a_500()
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/infrastructure/services/{serviceId}/overrides")
|
|
{
|
|
Content = new StringContent("{ not valid json", System.Text.Encoding.UTF8, "application/json")
|
|
};
|
|
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
|
|
|
var response = await client.SendAsync(request);
|
|
|
|
Assert.True(
|
|
(int)response.StatusCode < 500,
|
|
$"Malformed JSON must never reach a 5xx response, got {response.StatusCode}.");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_valid_override_can_be_reset_by_removing_it_and_the_removal_is_idempotent_to_retry_by_id()
|
|
{
|
|
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
|
|
|
var setResponse = await SetupHelper.PostWithCsrfAsync(
|
|
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
|
|
new SetManualOverrideRequest("runtimeStatus", JsonSerializer.SerializeToElement("gecorrigeerd"), "Reden."));
|
|
Assert.Equal(HttpStatusCode.OK, setResponse.StatusCode);
|
|
var created = await setResponse.Content.ReadFromJsonAsync<JsonElement>();
|
|
var overrideId = created.GetProperty("id").GetGuid();
|
|
|
|
var removeResponse = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/infrastructure/overrides/{overrideId}");
|
|
Assert.Equal(HttpStatusCode.NoContent, removeResponse.StatusCode);
|
|
|
|
var detail = await client.GetFromJsonAsync<InfrastructureServiceDetail>($"/api/v1/infrastructure/services/{serviceId}");
|
|
Assert.Empty(detail!.Overrides);
|
|
|
|
// Removing an already-removed override is a clear not-found, never a crash.
|
|
var retryRemove = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/infrastructure/overrides/{overrideId}");
|
|
Assert.Equal(HttpStatusCode.NotFound, retryRemove.StatusCode);
|
|
}
|
|
|
|
private static SynchronizeInventoryRequest MinimalSnapshot(string hash) =>
|
|
new(
|
|
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
|
|
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
|
|
[new InventoryServiceRequest(
|
|
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", "running",
|
|
"Healthy", "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
|
|
[], [], [], [], [], [], [], [], [])]);
|
|
}
|