Files
itworx-knowledge-hub-Public/tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/ManualOverrideCatalogTests.cs
T

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",
[], [], [], [], [], [], [], [], [])]);
}