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; /// /// The explicit per-InfrastructureObjectKind override field catalog /// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H3): /// ManualOverride must never accept an arbitrary field name or unconstrained /// JSON. /// [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( "/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( "/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("\"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("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("{\"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("\"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("\"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(); 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($"/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", [], [], [], [], [], [], [], [], [])]); }