Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
+249
@@ -0,0 +1,249 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Contracts.Admin;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Contracts.Projects;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Projects;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side validation for infrastructure editorial profiles
|
||||
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H2): invalid
|
||||
/// input must always come back as a specific RFC 7807 422 with a Dutch, actionable
|
||||
/// message — never a database exception, never a silent 500.
|
||||
/// </summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class EditorialProfileValidationTests(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);
|
||||
}
|
||||
|
||||
private static UpdateEditorialProfileRequest ValidRequest(
|
||||
string? displayName = "Naam", string? summary = "Samenvatting", string? category = "Other",
|
||||
string? repositoryUrl = null, string? liveUrl = null, string? backupStatus = null, string? monitoringUrl = null,
|
||||
Guid? ownerId = null, Guid? projectId = null, IReadOnlyList<string>? tags = null) =>
|
||||
new(displayName, summary, category, "Doel", ownerId, projectId, repositoryUrl, liveUrl, backupStatus,
|
||||
monitoringUrl, null, null, null, null, tags ?? [], false, false, false);
|
||||
|
||||
[Fact]
|
||||
public async Task A_display_name_over_the_maximum_length_is_rejected_as_422_not_a_database_error()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
var tooLong = new string('a', 301);
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(displayName: tooLong), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
Assert.Equal(ProblemTypes.Validation, problem!.Type);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("data:text/html,<script>alert(1)</script>")]
|
||||
[InlineData("file:///etc/passwd")]
|
||||
[InlineData("ftp://example.test/repo")]
|
||||
[InlineData("not a url")]
|
||||
public async Task An_unsafe_or_malformed_repository_url_scheme_is_rejected(string unsafeUrl)
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(repositoryUrl: unsafeUrl), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_plain_http_and_https_repository_url_is_accepted()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
|
||||
ValidRequest(repositoryUrl: "https://example.test/repo", liveUrl: "http://192.0.2.10:1235"), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_category_is_rejected()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(category: "NietBestaandeCategorie"), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_backup_status_is_rejected()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(backupStatus: "OngeldigeStatus"), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Unknown")]
|
||||
[InlineData("Manual")]
|
||||
[InlineData("Automated")]
|
||||
[InlineData("AutomatedTested")]
|
||||
[InlineData("None")]
|
||||
public async Task Every_allowed_backup_status_value_is_accepted(string backupStatus)
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(backupStatus: backupStatus), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task More_than_the_maximum_number_of_tags_is_rejected()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
var tooManyTags = Enumerable.Range(0, 26).Select(i => $"label-{i}").ToArray();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(tags: tooManyTags), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_tags_are_rejected()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
|
||||
ValidRequest(tags: ["productie", "Productie"]), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_owner_id_that_is_not_a_workspace_member_is_rejected()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
|
||||
ValidRequest(ownerId: Guid.NewGuid()), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_owner_id_that_is_a_real_workspace_member_is_accepted()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var createUser = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/admin/users", new CreateUserRequest("Eigenaar Kandidaat", "eigenaar-kandidaat@itworx.test", "owner-strong-password", "Editor"));
|
||||
Assert.Equal(HttpStatusCode.OK, createUser.StatusCode);
|
||||
var user = await createUser.Content.ReadFromJsonAsync<UserSummaryResponse>();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
|
||||
ValidRequest(ownerId: user!.Id), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_project_id_that_does_not_exist_is_rejected()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
|
||||
ValidRequest(projectId: Guid.NewGuid()), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_project_id_belonging_to_a_different_workspace_is_rejected()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
// Cross-workspace linkage is not reachable through this single-workspace-per-
|
||||
// installation product's ordinary HTTP surface (there is no "create a second
|
||||
// workspace" endpoint) — seeded directly, matching this test suite's
|
||||
// established pattern for otherwise-unreachable setup states.
|
||||
Guid foreignProjectId;
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var foreignWorkspace = Workspace.Create(WorkspaceName.Create("Andere workspace"), DateTimeOffset.UtcNow);
|
||||
db.Workspaces.Add(foreignWorkspace);
|
||||
var project = Project.Create(
|
||||
foreignWorkspace.Id, "Project in andere workspace", "S.", ProjectPriority.Medium, "Start.",
|
||||
Guid.NewGuid(), Guid.NewGuid(), DateTimeOffset.UtcNow);
|
||||
db.Projects.Add(project);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
foreignProjectId = project.Id;
|
||||
}
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
|
||||
ValidRequest(projectId: foreignProjectId), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_project_id_belonging_to_this_workspace_is_accepted()
|
||||
{
|
||||
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
|
||||
|
||||
var createUser = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/admin/users", new CreateUserRequest("Projecteigenaar", "project-eigenaar@itworx.test", "owner-strong-password", "Editor"));
|
||||
var user = await createUser.Content.ReadFromJsonAsync<UserSummaryResponse>();
|
||||
var createProject = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/projects", new CreateProjectRequest("Gekoppeld project", "S.", "Medium", "Start.", user!.Id));
|
||||
Assert.Equal(HttpStatusCode.OK, createProject.StatusCode);
|
||||
var project = await createProject.Content.ReadFromJsonAsync<ProjectDetail>();
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
|
||||
ValidRequest(projectId: project!.Id), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.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",
|
||||
[], [], [], [], [], [], [], [], [])]);
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
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>
|
||||
/// V2-R5 (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 6): the manual-
|
||||
/// override mechanism was already fully generic at the use-case/repository layer
|
||||
/// (<c>SetManualOverrideUseCase</c>, <c>IInfrastructureRepository.TargetExistsAsync</c>)
|
||||
/// — only the field catalog (previously an explicit, deliberately empty stub for
|
||||
/// every kind but Service) and a reachable endpoint were missing. Proves the field
|
||||
/// catalog is now real for every non-Service kind the domain models, and that the new
|
||||
/// generic <c>POST /infrastructure/overrides</c> endpoint actually works end to end
|
||||
/// for several of them.
|
||||
/// </summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class ExpandedManualOverrideCatalogTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Host", "hostname")]
|
||||
[InlineData("Component", "name")]
|
||||
[InlineData("Endpoint", "scheme")]
|
||||
[InlineData("Network", "name")]
|
||||
[InlineData("Volume", "name")]
|
||||
[InlineData("Database", "name")]
|
||||
[InlineData("Deployment", "repositoryUrl")]
|
||||
[InlineData("Backup", "method")]
|
||||
public async Task Every_non_service_object_kind_now_has_a_real_non_empty_override_catalog(string targetKind, string expectedField)
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
|
||||
var catalog = await client.GetFromJsonAsync<ManualOverrideFieldCatalogResponse>(
|
||||
$"/api/v1/infrastructure/overrides/catalog?targetKind={targetKind}");
|
||||
|
||||
Assert.NotNull(catalog);
|
||||
Assert.NotEmpty(catalog.Fields);
|
||||
Assert.Contains(catalog.Fields, field => field.FieldName == expectedField);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_host_fact_can_be_corrected_through_the_generic_endpoint()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
|
||||
var (hostId, _, _, _, _, _, _, _) = await SeedFullServiceGraphAsync(client);
|
||||
|
||||
var response = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/overrides",
|
||||
new SetInfrastructureOverrideRequest("Host", hostId, "hostname", JsonSerializer.SerializeToElement("gecorrigeerde-hostnaam"), "Reden."));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Network", "internal")]
|
||||
[InlineData("Volume", "readOnly")]
|
||||
public async Task A_boolean_shaped_fact_is_overridable_as_a_true_false_enum(string targetKind, string fieldName)
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
|
||||
var (_, _, networkId, volumeId, _, _, _, _) = await SeedFullServiceGraphAsync(client);
|
||||
var targetId = targetKind == "Network" ? networkId : volumeId;
|
||||
|
||||
var response = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/overrides",
|
||||
new SetInfrastructureOverrideRequest(targetKind, targetId, fieldName, JsonSerializer.SerializeToElement("true"), "Reden."));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var invalid = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/overrides",
|
||||
new SetInfrastructureOverrideRequest(targetKind, targetId, fieldName, JsonSerializer.SerializeToElement("misschien"), "Ongeldig."));
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, invalid.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_target_kind_string_is_rejected_not_a_crash()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
var (hostId, _, _, _, _, _, _, _) = await SeedFullServiceGraphAsync(client);
|
||||
|
||||
var response = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/overrides",
|
||||
new SetInfrastructureOverrideRequest("NietBestaandType", hostId, "naam", JsonSerializer.SerializeToElement("x"), null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_target_id_that_does_not_exist_for_the_given_kind_is_not_found()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
await SeedFullServiceGraphAsync(client);
|
||||
|
||||
var response = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/overrides",
|
||||
new SetInfrastructureOverrideRequest("Host", Guid.NewGuid(), "hostname", JsonSerializer.SerializeToElement("x"), null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_override_set_through_the_generic_endpoint_can_be_removed_through_the_existing_generic_delete()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
var (hostId, _, _, _, _, _, _, _) = await SeedFullServiceGraphAsync(client);
|
||||
|
||||
var setResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/overrides",
|
||||
new SetInfrastructureOverrideRequest("Host", hostId, "operatingSystem", JsonSerializer.SerializeToElement("Custom OS"), "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);
|
||||
}
|
||||
|
||||
/// <summary>Seeds one service with one of every nested fact kind via a single real
|
||||
/// inventory sync, then resolves each fact's real generated ID through the normal
|
||||
/// service-detail endpoint — never a raw SQL insert, so this also incidentally
|
||||
/// re-proves the sync path itself creates every fact kind correctly.</summary>
|
||||
private static async Task<(Guid HostId, Guid ServiceId, Guid NetworkId, Guid VolumeId, Guid DatabaseId, Guid EndpointId, Guid DeploymentId, Guid BackupId)>
|
||||
SeedFullServiceGraphAsync(HttpClient client)
|
||||
{
|
||||
var hash = $"hash-{Guid.NewGuid():N}";
|
||||
var request = new SynchronizeInventoryRequest(
|
||||
$"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",
|
||||
[new InventoryComponentRequest("component:app", "app", "container", "rt-1", "running", "itworx/app:v2", "High")],
|
||||
[new InventoryEndpointRequest("endpoint:main", "https", "wiki.example.com", 1235, 8080, "https://wiki.example.com", true, "High")],
|
||||
[new InventoryNetworkRequest("network:main", "knowledge-net", "bridge", "local", false, "High")],
|
||||
[new InventoryVolumeRequest("volume:data", "knowledge-data", "/data", "local", false, 1024, "High")],
|
||||
[new InventoryDatabaseRequest("database:main", "knowledge_hub", "postgresql", "18", "db", 5432, false, "High")],
|
||||
[],
|
||||
[],
|
||||
[new InventoryDeploymentRequest("deployment:main", "https://gitea.example.com/itworx/knowledge-hub", "abc123", "itworx/knowledge-hub:v2", "sha256:example", DateTimeOffset.UtcNow, "High")],
|
||||
[new InventoryBackupRequest("backup:main", "pg_dump", "daily", DateTimeOffset.UtcNow.AddDays(-1), true, "High")])]);
|
||||
|
||||
var syncResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/infrastructure/inventory/snapshots", request);
|
||||
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
|
||||
|
||||
var overview = await client.GetFromJsonAsync<InfrastructureOverviewResponse>("/api/v1/infrastructure/overview");
|
||||
var hostId = Assert.Single(overview!.Hosts).Id;
|
||||
|
||||
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>("/api/v1/infrastructure/services?includeHidden=true");
|
||||
var serviceId = Assert.Single(services!.Items).Id;
|
||||
|
||||
var detail = await client.GetFromJsonAsync<InfrastructureServiceDetail>($"/api/v1/infrastructure/services/{serviceId}");
|
||||
Assert.NotNull(detail);
|
||||
|
||||
return (
|
||||
hostId,
|
||||
serviceId,
|
||||
Assert.Single(detail.Networks).Id,
|
||||
Assert.Single(detail.Volumes).Id,
|
||||
Assert.Single(detail.Databases).Id,
|
||||
Assert.Single(detail.Endpoints).Id,
|
||||
Assert.Single(detail.Deployments).Id,
|
||||
Assert.Single(detail.Backups).Id);
|
||||
}
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
|
||||
|
||||
/// <summary>
|
||||
/// The full-scale proof for infrastructure tables that
|
||||
/// <c>docs/v2/V2_ACCEPTANCE_MATRIX.md</c>'s "Search/query-plan proof at scale extended
|
||||
/// to infrastructure tables" row (previously `Planned`) asked for — the infrastructure
|
||||
/// equivalent of <c>SearchFullScaleTests</c> (V1 M8), closing V2-R4's open release
|
||||
/// risk 4 with real evidence rather than only the lighter-weight, routine-suite
|
||||
/// <c>InfrastructureCatalogScaleTests</c> (V2-R2, 3,000 services). Tagged
|
||||
/// "ReleaseScale" and excluded from the routine test run (matching
|
||||
/// <c>SearchFullScaleTests</c>'s own precedent) — seeding and measuring at this scale
|
||||
/// is a deliberate, occasional release-gate exercise. Run explicitly via:
|
||||
/// <c>dotnet test --filter "Category=ReleaseScale"</c>.
|
||||
/// </summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
[Trait("Category", "ReleaseScale")]
|
||||
public sealed class InfrastructureCatalogReleaseScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
private const int ServiceCount = 50_000;
|
||||
private const int SampleCount = 30;
|
||||
|
||||
[Fact]
|
||||
public async Task Infrastructure_catalog_reads_stay_correct_and_bounded_at_50000_services()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
|
||||
var report = new StringBuilder();
|
||||
report.AppendLine(CultureInfo.InvariantCulture, $"V2-R4 infrastructure-catalog release-scale report — {DateTimeOffset.UtcNow:O}");
|
||||
report.AppendLine(CultureInfo.InvariantCulture, $"Target: {ServiceCount:N0} services (docs/v2/V2_ACCEPTANCE_MATRIX.md, previously 'Planned').");
|
||||
|
||||
Guid workspaceId;
|
||||
var seedStopwatch = Stopwatch.StartNew();
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
|
||||
workspaceId = workspace.Id;
|
||||
|
||||
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
|
||||
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await SeedServicesAsync(connection, workspaceId);
|
||||
await SeedFactsAsync(connection, workspaceId);
|
||||
await PrepareReadMeasurementAsync(connection);
|
||||
}
|
||||
|
||||
seedStopwatch.Stop();
|
||||
report.AppendLine(CultureInfo.InvariantCulture, $"Seed time: {seedStopwatch.Elapsed}.");
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
|
||||
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var countCommand = new NpgsqlCommand(
|
||||
"SELECT (SELECT count(*) FROM infrastructure_services), (SELECT count(*) FROM service_components)",
|
||||
connection)
|
||||
{
|
||||
CommandTimeout = 300
|
||||
};
|
||||
await using var reader = await countCommand.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync(), "Release-scale row-count query returned no row.");
|
||||
var actualServices = reader.GetInt64(0);
|
||||
var actualComponents = reader.GetInt64(1);
|
||||
report.AppendLine(CultureInfo.InvariantCulture, $"Actual seeded rows: infrastructure_services={actualServices:N0}, service_components={actualComponents:N0}.");
|
||||
Assert.True(actualServices >= ServiceCount, $"Expected at least {ServiceCount:N0} services, found {actualServices:N0}.");
|
||||
}
|
||||
|
||||
// --- correctness: bounded, correctly-cursored pagination still holds at this scale ---
|
||||
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>("/api/v1/infrastructure/services?limit=50");
|
||||
Assert.NotNull(firstPage);
|
||||
Assert.Equal(50, firstPage.Items.Count);
|
||||
Assert.False(string.IsNullOrEmpty(firstPage.NextCursor));
|
||||
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
$"/api/v1/infrastructure/services?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
|
||||
Assert.NotNull(secondPage);
|
||||
Assert.Equal(50, secondPage.Items.Count);
|
||||
Assert.Empty(firstPage.Items.Select(item => item.Id).Intersect(secondPage.Items.Select(item => item.Id)));
|
||||
report.AppendLine("Correctness: bounded first/second page, disjoint, correct size. PASS.");
|
||||
|
||||
// --- latency: p50/p95/p99 for the catalog's first page and a deep-cursor page ---
|
||||
report.AppendLine();
|
||||
report.AppendLine("Latency (ms), 30 samples per query shape:");
|
||||
var measurements = new[]
|
||||
{
|
||||
await MeasureAsync(client, report, "first page (limit=50)", "/api/v1/infrastructure/services?limit=50"),
|
||||
await MeasureAsync(client, report, "deep cursor page (after page 1)", $"/api/v1/infrastructure/services?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}")
|
||||
};
|
||||
|
||||
// --- query plan + index sizes ---
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
|
||||
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
report.AppendLine();
|
||||
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the catalog's first-page query:");
|
||||
report.AppendLine(await ExplainCatalogPageAsync(connection, workspaceId));
|
||||
|
||||
report.AppendLine();
|
||||
report.AppendLine("Index sizes:");
|
||||
report.AppendLine(await IndexSizesAsync(connection, "infrastructure_services"));
|
||||
}
|
||||
|
||||
var reportPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "artifacts", "v2-r4-infrastructure-scale-report.txt");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
|
||||
await File.WriteAllTextAsync(reportPath, report.ToString());
|
||||
|
||||
foreach (var measurement in measurements)
|
||||
{
|
||||
// Generous release-gate bound, matching SearchFullScaleTests' own — proving
|
||||
// the paginated, counts-only query stays sub-second at true release scale,
|
||||
// not a tight micro-benchmark.
|
||||
Assert.True(
|
||||
measurement.P95 < 1000,
|
||||
$"{measurement.Label}: p95 was {measurement.P95:F1}ms, expected well under 1000ms at {ServiceCount:N0}-service scale.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SeedServicesAsync(NpgsqlConnection connection, Guid workspaceId)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO infrastructure_services (
|
||||
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
|
||||
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
|
||||
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
|
||||
SELECT
|
||||
gen_random_uuid(), @workspaceId, NULL,
|
||||
'docker:release-scale-service-' || n, 'release-scale-service-' || lpad(n::text, 6, '0'),
|
||||
'container', 'running', 1, 'itworx/release-scale:' || n, '', '', false, false, false,
|
||||
1, 2, gen_random_uuid(), now(), now()
|
||||
FROM generate_series(1, @serviceCount) AS n
|
||||
""";
|
||||
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
command.Parameters.AddWithValue("serviceCount", ServiceCount);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedFactsAsync(NpgsqlConnection connection, Guid workspaceId)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO service_components (
|
||||
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
|
||||
name, kind, runtime_id, status, image_reference)
|
||||
SELECT
|
||||
gen_random_uuid(), @workspaceId, s.id, 'container:' || c.n, 2, gen_random_uuid(), now(),
|
||||
'component-' || c.n, 'container', 'rt-' || c.n, 'running', ''
|
||||
FROM infrastructure_services s
|
||||
CROSS JOIN generate_series(1, 2) AS c(n)
|
||||
WHERE s.workspace_id = @workspaceId
|
||||
""";
|
||||
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task PrepareReadMeasurementAsync(NpgsqlConnection connection)
|
||||
{
|
||||
foreach (var statement in new[]
|
||||
{
|
||||
"VACUUM (ANALYZE) infrastructure_services;",
|
||||
"VACUUM (ANALYZE) service_components;",
|
||||
"CHECKPOINT;"
|
||||
})
|
||||
{
|
||||
await using var command = new NpgsqlCommand(statement, connection) { CommandTimeout = 300 };
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<LatencyMeasurement> MeasureAsync(HttpClient client, StringBuilder report, string label, string path)
|
||||
{
|
||||
var samples = new List<double>(SampleCount);
|
||||
for (var i = 0; i < SampleCount; i++)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var response = await client.GetAsync(path);
|
||||
stopwatch.Stop();
|
||||
response.EnsureSuccessStatusCode();
|
||||
samples.Add(stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
samples.Sort();
|
||||
var p50 = Percentile(samples, 0.50);
|
||||
var p95 = Percentile(samples, 0.95);
|
||||
var p99 = Percentile(samples, 0.99);
|
||||
report.AppendLine(CultureInfo.InvariantCulture, $" {label}: p50={p50:F1}ms p95={p95:F1}ms p99={p99:F1}ms (min={samples[0]:F1}ms max={samples[^1]:F1}ms)");
|
||||
|
||||
return new LatencyMeasurement(label, p95);
|
||||
}
|
||||
|
||||
private static double Percentile(IReadOnlyList<double> sortedSamples, double percentile)
|
||||
{
|
||||
var index = (int)Math.Ceiling(percentile * sortedSamples.Count) - 1;
|
||||
return sortedSamples[Math.Clamp(index, 0, sortedSamples.Count - 1)];
|
||||
}
|
||||
|
||||
private static async Task<string> ExplainCatalogPageAsync(NpgsqlConnection connection, Guid workspaceId)
|
||||
{
|
||||
const string sql = """
|
||||
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
|
||||
SELECT id, detected_name
|
||||
FROM infrastructure_services
|
||||
WHERE workspace_id = @workspaceId
|
||||
ORDER BY detected_name, id
|
||||
LIMIT 51
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
var lines = new StringBuilder();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
lines.AppendLine(" " + reader.GetString(0));
|
||||
}
|
||||
|
||||
return lines.ToString();
|
||||
}
|
||||
|
||||
private static async Task<string> IndexSizesAsync(NpgsqlConnection connection, string tableName)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass))
|
||||
FROM pg_indexes
|
||||
WHERE tablename = @tableName
|
||||
ORDER BY indexname
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tableName", tableName);
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
var lines = new StringBuilder();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
lines.AppendLine(CultureInfo.InvariantCulture, $" {reader.GetString(0)}: {reader.GetString(1)}");
|
||||
}
|
||||
|
||||
return lines.ToString();
|
||||
}
|
||||
|
||||
private sealed record LatencyMeasurement(string Label, double P95);
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
|
||||
|
||||
/// <summary>A large workspace's service catalog stays bounded and fast to page through
|
||||
/// (V2-R2, docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md, Finding
|
||||
/// A): before this milestone, listing services always loaded all nine related fact
|
||||
/// tables for every service in the workspace, unpaginated. This proves the catalog
|
||||
/// endpoint now serves one bounded, correctly-cursored page regardless of how many
|
||||
/// services (and related facts) exist in total — mirroring
|
||||
/// <c>PageTreeScaleTests</c>'s established "bounded and fast, not a precise
|
||||
/// benchmark" pattern for the knowledge tree.</summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class InfrastructureCatalogScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
private const int ServiceCount = 3_000;
|
||||
|
||||
[Fact]
|
||||
public async Task Fetching_one_page_of_a_3000_service_catalog_stays_fast_and_bounded()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
|
||||
var workspaceId = await SeedServicesDirectlyAsync();
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?limit=50");
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.NotNull(firstPage);
|
||||
Assert.Equal(50, firstPage.Items.Count);
|
||||
Assert.False(string.IsNullOrEmpty(firstPage.NextCursor));
|
||||
// Every summary row must still carry the fact counts the catalog card renders,
|
||||
// even though the query behind it now fetches only counts, not full related rows.
|
||||
Assert.All(firstPage.Items, item =>
|
||||
{
|
||||
Assert.Equal(2, item.ComponentCount);
|
||||
Assert.Equal(1, item.EndpointCount);
|
||||
Assert.Equal(1, item.VolumeCount);
|
||||
Assert.Equal(1, item.DatabaseCount);
|
||||
});
|
||||
|
||||
// Generous bound for a CI/dev machine: the point is "bounded and fast",
|
||||
// regardless of the {ServiceCount} total services and their ~5x related facts.
|
||||
Assert.True(
|
||||
stopwatch.Elapsed < TimeSpan.FromSeconds(5),
|
||||
$"Fetching one bounded page of services took {stopwatch.Elapsed}, expected well under 5s at {ServiceCount} total services.");
|
||||
|
||||
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
$"/api/v1/infrastructure/services?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
|
||||
Assert.NotNull(secondPage);
|
||||
Assert.Equal(50, secondPage.Items.Count);
|
||||
Assert.Empty(firstPage.Items.Select(item => item.Id).Intersect(secondPage.Items.Select(item => item.Id)));
|
||||
|
||||
_ = workspaceId;
|
||||
}
|
||||
|
||||
private async Task<Guid> SeedServicesDirectlyAsync()
|
||||
{
|
||||
// Seeded through a handful of server-side INSERT...SELECT statements (one round
|
||||
// trip per table, not one per row or per fact) — the same generate_series
|
||||
// pattern SearchFullScaleTests already established for bulk-seeding at scale.
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
|
||||
var workspaceId = workspace.Id;
|
||||
|
||||
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
|
||||
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var snapshotId = Guid.NewGuid();
|
||||
|
||||
const string servicesSql = """
|
||||
INSERT INTO infrastructure_services (
|
||||
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
|
||||
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
|
||||
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
|
||||
SELECT
|
||||
gen_random_uuid(), @workspaceId, NULL,
|
||||
'docker:schaal-service-' || n, 'schaal-service-' || lpad(n::text, 5, '0'),
|
||||
'container', 'running', 1, 'itworx/schaal:' || n, '', '', false, false, false,
|
||||
1, 2, @snapshotId, now(), now()
|
||||
FROM generate_series(1, @serviceCount) AS n
|
||||
""";
|
||||
await using (var command = new NpgsqlCommand(servicesSql, connection) { CommandTimeout = 120 })
|
||||
{
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
command.Parameters.AddWithValue("serviceCount", ServiceCount);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
const string componentsSql = """
|
||||
INSERT INTO service_components (
|
||||
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
|
||||
name, kind, runtime_id, status, image_reference)
|
||||
SELECT
|
||||
gen_random_uuid(), @workspaceId, s.id, 'container:' || c.n, 2, @snapshotId, now(),
|
||||
'component-' || c.n, 'container', 'rt-' || c.n, 'running', ''
|
||||
FROM infrastructure_services s
|
||||
CROSS JOIN generate_series(1, 2) AS c(n)
|
||||
WHERE s.workspace_id = @workspaceId
|
||||
""";
|
||||
await using (var command = new NpgsqlCommand(componentsSql, connection) { CommandTimeout = 120 })
|
||||
{
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
const string endpointsSql = """
|
||||
INSERT INTO infrastructure_endpoints (
|
||||
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
|
||||
scheme, host, published_port, target_port, url, is_public)
|
||||
SELECT
|
||||
gen_random_uuid(), @workspaceId, s.id, 'port:main', 2, @snapshotId, now(),
|
||||
'http', '', NULL, NULL, '', false
|
||||
FROM infrastructure_services s
|
||||
WHERE s.workspace_id = @workspaceId
|
||||
""";
|
||||
await using (var command = new NpgsqlCommand(endpointsSql, connection) { CommandTimeout = 120 })
|
||||
{
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
const string volumesSql = """
|
||||
INSERT INTO infrastructure_volumes (
|
||||
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
|
||||
name, mount_path, storage_class, read_only, size_bytes)
|
||||
SELECT
|
||||
gen_random_uuid(), @workspaceId, s.id, 'volume:main', 2, @snapshotId, now(),
|
||||
'data', '/data', '', false, NULL
|
||||
FROM infrastructure_services s
|
||||
WHERE s.workspace_id = @workspaceId
|
||||
""";
|
||||
await using (var command = new NpgsqlCommand(volumesSql, connection) { CommandTimeout = 120 })
|
||||
{
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
const string databasesSql = """
|
||||
INSERT INTO infrastructure_databases (
|
||||
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
|
||||
name, engine, version, host, port, host_port_published)
|
||||
SELECT
|
||||
gen_random_uuid(), @workspaceId, s.id, 'database:main', 2, @snapshotId, now(),
|
||||
'main', 'postgresql', '17', '', NULL, false
|
||||
FROM infrastructure_services s
|
||||
WHERE s.workspace_id = @workspaceId
|
||||
""";
|
||||
await using (var command = new NpgsqlCommand(databasesSql, connection) { CommandTimeout = 120 })
|
||||
{
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
return workspaceId;
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Contracts.Auth;
|
||||
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Contracts.Projects;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
|
||||
|
||||
/// <summary>
|
||||
/// V2-R5 (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 4): `GET
|
||||
/// /infrastructure/storage` was a fully unbounded, unpaginated listing, and
|
||||
/// `GET /infrastructure/services` had no server-side project filter — the
|
||||
/// Project workspace fetched up to 200 services and filtered by project client-side.
|
||||
/// Proves the fix: real server-side cursor pagination (no duplicates, no gaps, stable
|
||||
/// across pages), a server-side text search, and a server-side project filter.
|
||||
/// </summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class InfrastructurePaginationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
private const int VolumeCount = 130;
|
||||
|
||||
[Fact]
|
||||
public async Task Storage_pages_through_every_volume_exactly_once_with_no_duplicates_or_gaps()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
await SeedVolumesDirectlyAsync();
|
||||
|
||||
var seenIds = new HashSet<Guid>();
|
||||
string? cursor = null;
|
||||
var pageCount = 0;
|
||||
do
|
||||
{
|
||||
var url = "/api/v1/infrastructure/storage?limit=20" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
|
||||
var page = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(url);
|
||||
Assert.NotNull(page);
|
||||
Assert.True(page.Items.Count <= 20);
|
||||
foreach (var item in page.Items)
|
||||
{
|
||||
Assert.True(seenIds.Add(item.Id), $"Volume {item.Id} appeared on more than one page.");
|
||||
}
|
||||
|
||||
cursor = page.NextCursor;
|
||||
pageCount++;
|
||||
Assert.True(pageCount < 50, "Too many pages — likely a pagination bug (cursor never advances / never terminates).");
|
||||
}
|
||||
while (cursor is not null);
|
||||
|
||||
Assert.Equal(VolumeCount, seenIds.Count);
|
||||
Assert.True(pageCount > 1, "Expected more than one page at this seeded volume count.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Storage_search_filters_server_side_not_only_within_the_first_loaded_page()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
await SeedVolumesDirectlyAsync();
|
||||
|
||||
// "schaal-service-00099" (a service near the end of the seeded range, unlikely
|
||||
// to appear on a single default-sized first page) must still be found — proving
|
||||
// the search runs server-side across the whole table, not client-side over
|
||||
// whatever page happened to load first.
|
||||
var result = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(
|
||||
"/api/v1/infrastructure/storage?search=schaal-service-00099&limit=20");
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Items);
|
||||
Assert.Contains("schaal-service-00099", result.Items[0].ServiceName);
|
||||
Assert.Null(result.NextCursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Services_search_filters_server_side_not_only_within_the_first_loaded_page()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
await SeedVolumesDirectlyAsync();
|
||||
|
||||
var result = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true&search=schaal-service-00099&limit=20");
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Items);
|
||||
Assert.Contains("schaal-service-00099", result.Items[0].Name);
|
||||
Assert.Null(result.NextCursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Services_can_be_filtered_by_project_entirely_server_side()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
var (workspaceId, allServiceIds) = await SeedVolumesDirectlyAsync();
|
||||
|
||||
var session = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
|
||||
Assert.NotNull(session?.UserId);
|
||||
|
||||
var projectResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/projects",
|
||||
new CreateProjectRequest(
|
||||
$"Paginatie-project {Guid.NewGuid():N}", "Testproject voor paginatiedekking.", "Medium", "# Status", session.UserId.Value));
|
||||
Assert.Equal(System.Net.HttpStatusCode.OK, projectResponse.StatusCode);
|
||||
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
|
||||
Assert.NotNull(project);
|
||||
var projectId = project.Id;
|
||||
|
||||
// Link exactly 3 of the 130 seeded services to this project through the real
|
||||
// editorial-profile endpoint — proving the filter is a real server-side WHERE
|
||||
// against genuine domain state, not a client-side slice of an unfiltered page.
|
||||
var linkedIds = allServiceIds.Take(3).ToArray();
|
||||
foreach (var serviceId in linkedIds)
|
||||
{
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
|
||||
new { isFavorite = false, isHidden = false, isArchived = false, projectId }, "0");
|
||||
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
_ = workspaceId;
|
||||
|
||||
var filtered = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
$"/api/v1/infrastructure/services?includeHidden=true&projectId={projectId}&limit=200");
|
||||
Assert.NotNull(filtered);
|
||||
Assert.Equal(3, filtered.Items.Count);
|
||||
Assert.Equal(linkedIds.ToHashSet(), filtered.Items.Select(item => item.Id).ToHashSet());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Services_status_filter_is_applied_before_cursor_pagination()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
var (_, serviceIds) = await SeedVolumesDirectlyAsync();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
await db.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}");
|
||||
}
|
||||
|
||||
// The unhealthy service is deliberately the final seeded row. A server-side
|
||||
// WHERE is required: a local filter over a first page could never find it.
|
||||
var result = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=20");
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal(serviceIds[^1], result.Items[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Services_report_the_filtered_total_not_the_size_of_the_returned_page()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
var (_, serviceIds) = await SeedVolumesDirectlyAsync();
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
await db.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}");
|
||||
}
|
||||
|
||||
// A deliberately small page over a deliberately large catalog: the operator
|
||||
// must be told how many services exist behind the filter, not how many fitted
|
||||
// on the first page.
|
||||
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true&limit=10");
|
||||
Assert.NotNull(firstPage);
|
||||
Assert.Equal(10, firstPage.Items.Count);
|
||||
Assert.NotNull(firstPage.NextCursor);
|
||||
Assert.Equal(serviceIds.Count, firstPage.TotalCount);
|
||||
|
||||
// The total tracks the filter, and stays stable while paging through it.
|
||||
var attention = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=10");
|
||||
Assert.NotNull(attention);
|
||||
Assert.Equal(1, attention.TotalCount);
|
||||
|
||||
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
$"/api/v1/infrastructure/services?includeHidden=true&limit=10&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
|
||||
Assert.NotNull(secondPage);
|
||||
Assert.Equal(serviceIds.Count, secondPage.TotalCount);
|
||||
}
|
||||
|
||||
private async Task<(Guid WorkspaceId, IReadOnlyList<Guid> ServiceIds)> SeedVolumesDirectlyAsync()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
|
||||
var workspaceId = workspace.Id;
|
||||
|
||||
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
|
||||
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var snapshotId = Guid.NewGuid();
|
||||
|
||||
const string servicesSql = """
|
||||
INSERT INTO infrastructure_services (
|
||||
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
|
||||
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
|
||||
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
|
||||
SELECT
|
||||
gen_random_uuid(), @workspaceId, NULL,
|
||||
'docker:schaal-service-' || n, 'schaal-service-' || lpad(n::text, 5, '0'),
|
||||
'container', 'running', 1, 'itworx/schaal:' || n, '', '', false, false, false,
|
||||
1, 2, @snapshotId, now(), now()
|
||||
FROM generate_series(1, @count) AS n
|
||||
RETURNING id
|
||||
""";
|
||||
var serviceIds = new List<Guid>();
|
||||
await using (var command = new NpgsqlCommand(servicesSql, connection) { CommandTimeout = 60 })
|
||||
{
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
command.Parameters.AddWithValue("count", VolumeCount);
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
serviceIds.Add(reader.GetGuid(0));
|
||||
}
|
||||
}
|
||||
|
||||
const string volumesSql = """
|
||||
INSERT INTO infrastructure_volumes (
|
||||
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
|
||||
name, mount_path, storage_class, read_only, size_bytes)
|
||||
SELECT
|
||||
gen_random_uuid(), @workspaceId, s.id, 'volume:main', 2, @snapshotId, now(),
|
||||
'data', '/data', '', false, NULL
|
||||
FROM infrastructure_services s
|
||||
WHERE s.workspace_id = @workspaceId
|
||||
""";
|
||||
await using (var command = new NpgsqlCommand(volumesSql, connection) { CommandTimeout = 60 })
|
||||
{
|
||||
command.Parameters.AddWithValue("workspaceId", workspaceId);
|
||||
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
return (workspaceId, serviceIds);
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Contracts.Admin;
|
||||
using ItWorx.KnowledgeHub.Contracts.Automation;
|
||||
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class InfrastructureSynchronizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Inventory_facts_are_typed_and_editorial_knowledge_survives_later_snapshots()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var firstSyncResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-001", includeService: true, "running"));
|
||||
Assert.Equal(HttpStatusCode.OK, firstSyncResponse.StatusCode);
|
||||
|
||||
var firstSync = await firstSyncResponse.Content.ReadFromJsonAsync<InventorySyncResponse>();
|
||||
Assert.NotNull(firstSync);
|
||||
Assert.Equal(1, firstSync.AddedServices);
|
||||
|
||||
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true");
|
||||
var service = Assert.Single(services!.Items);
|
||||
|
||||
var detailResponse = await client.GetAsync($"/api/v1/infrastructure/services/{service.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||
Assert.Equal("0", SetupHelper.GetETag(detailResponse));
|
||||
var detail = await detailResponse.Content.ReadFromJsonAsync<InfrastructureServiceDetail>();
|
||||
Assert.NotNull(detail);
|
||||
Assert.Single(detail.Components);
|
||||
Assert.Single(detail.Endpoints);
|
||||
Assert.Single(detail.Volumes);
|
||||
Assert.Single(detail.Databases);
|
||||
|
||||
var storage = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(
|
||||
"/api/v1/infrastructure/storage");
|
||||
var storageRow = Assert.Single(storage!.Items);
|
||||
Assert.Equal(service.Id, storageRow.ServiceId);
|
||||
Assert.Equal(service.Name, storageRow.ServiceName);
|
||||
Assert.Equal("/app/attachments", storageRow.MountPath);
|
||||
|
||||
var profileResponse = await SetupHelper.PutWithCsrfAsync(
|
||||
client,
|
||||
$"/api/v1/infrastructure/services/{service.Id}/editorial-profile",
|
||||
new UpdateEditorialProfileRequest(
|
||||
"ItWorx Knowledge Hub", "Centrale technische kennisomgeving.", "Knowledge", "Documentatie",
|
||||
null, null, "https://example.test/repository", "http://192.0.2.10:1235", "AutomatedTested",
|
||||
string.Empty, "Onderhoudsnotitie", "Capaciteitsrisico", "Pull en deploy", "Herstel vorig image",
|
||||
["knowledge", "production"], true, false, false),
|
||||
"0");
|
||||
Assert.Equal(HttpStatusCode.OK, profileResponse.StatusCode);
|
||||
var profileETag = SetupHelper.GetETag(profileResponse);
|
||||
Assert.False(string.IsNullOrWhiteSpace(profileETag));
|
||||
|
||||
// "detectedName" is a real InfrastructureService inventory fact and therefore a
|
||||
// legitimate ManualOverrideCatalog field (docs/adr/ADR-025-v2-r1-data-integrity-
|
||||
// remediation.md, audit finding H3) — "category" (used here before H3) was never
|
||||
// actually one: it is an EditorialProfile field with its own dedicated write
|
||||
// path (the editorial-profile PUT above), and the catalog now correctly rejects
|
||||
// it as an override field name.
|
||||
var overrideResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client,
|
||||
$"/api/v1/infrastructure/services/{service.Id}/overrides",
|
||||
new SetManualOverrideRequest(
|
||||
"detectedName", JsonSerializer.Deserialize<JsonElement>("\"itworx-knowledge-hub (gecorrigeerd)\""), "Automatisch gedetecteerde naam was onduidelijk."));
|
||||
Assert.Equal(HttpStatusCode.OK, overrideResponse.StatusCode);
|
||||
|
||||
var secondSyncResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-002", includeService: true, "healthy"));
|
||||
Assert.Equal(HttpStatusCode.OK, secondSyncResponse.StatusCode);
|
||||
|
||||
var afterSync = await client.GetFromJsonAsync<InfrastructureServiceDetail>(
|
||||
$"/api/v1/infrastructure/services/{service.Id}");
|
||||
Assert.NotNull(afterSync);
|
||||
Assert.Equal("ItWorx Knowledge Hub", afterSync.EditorialProfile!.DisplayName);
|
||||
Assert.Equal("Centrale technische kennisomgeving.", afterSync.EditorialProfile.Summary);
|
||||
Assert.Single(afterSync.Overrides);
|
||||
Assert.Equal("healthy", afterSync.Service.RuntimeStatus);
|
||||
|
||||
var changes = await client.GetFromJsonAsync<IReadOnlyList<InventoryChangeResponse>>(
|
||||
"/api/v1/infrastructure/changes?limit=100");
|
||||
var runtimeChange = Assert.Single(changes!, change =>
|
||||
change.TargetKind == "Service" && change.ChangeKind == "Updated"
|
||||
&& change.ChangedFields.Contains("runtimeStatus"));
|
||||
Assert.Equal(service.Id, runtimeChange.TargetId);
|
||||
Assert.Equal("docker:knowledge-hub", runtimeChange.SourceKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_disappeared_service_is_marked_missing_and_an_identical_snapshot_is_idempotent()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-missing-1", includeService: true, "running"));
|
||||
|
||||
var missingResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-missing-2", includeService: false, "stopped"));
|
||||
var missing = await missingResponse.Content.ReadFromJsonAsync<InventorySyncResponse>();
|
||||
Assert.NotNull(missing);
|
||||
Assert.Equal(1, missing.MissingServices);
|
||||
|
||||
var retryResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-missing-2", includeService: false, "stopped"));
|
||||
var retry = await retryResponse.Content.ReadFromJsonAsync<InventorySyncResponse>();
|
||||
Assert.True(retry!.AlreadyImported);
|
||||
|
||||
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true");
|
||||
Assert.Equal("Missing", Assert.Single(services!.Items).Presence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Archiving_a_removed_service_removes_it_from_active_signals_and_totals_but_preserves_its_detail()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var sync = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/snapshots",
|
||||
Snapshot("hash-archive-signal", includeService: true, "stopped", health: "Unhealthy"));
|
||||
Assert.Equal(HttpStatusCode.OK, sync.StatusCode);
|
||||
|
||||
var proposal = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
|
||||
var accept = await SetupHelper.PostWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{proposal.Id}/review",
|
||||
new ReviewInfrastructureServiceRequest("Accept", "Bestaande service voor archiefregressie."));
|
||||
Assert.Equal(HttpStatusCode.OK, accept.StatusCode);
|
||||
|
||||
var activeSignals = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?status=attention");
|
||||
Assert.NotNull(activeSignals);
|
||||
Assert.Equal(proposal.Id, Assert.Single(activeSignals.Items).Id);
|
||||
|
||||
var beforeArchive = await client.GetAsync($"/api/v1/infrastructure/services/{proposal.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, beforeArchive.StatusCode);
|
||||
var archive = await SetupHelper.PutWithCsrfAsync(
|
||||
client,
|
||||
$"/api/v1/infrastructure/services/{proposal.Id}/editorial-profile",
|
||||
new UpdateEditorialProfileRequest(
|
||||
"Verwijderde testcontainer", "", "Other", "", null, null, null, null, null, null,
|
||||
null, null, null, null, [], false, false, true),
|
||||
SetupHelper.GetETag(beforeArchive));
|
||||
Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
|
||||
|
||||
// The ordinary catalogue is the dashboard's source for operational signals.
|
||||
// Archiving suppresses the warning while preserving the detail record for audit
|
||||
// and possible later unarchiving.
|
||||
var signalsAfterArchive = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?status=attention");
|
||||
Assert.NotNull(signalsAfterArchive);
|
||||
Assert.Empty(signalsAfterArchive.Items);
|
||||
Assert.Equal(0, signalsAfterArchive.TotalCount);
|
||||
|
||||
var overview = await client.GetFromJsonAsync<InfrastructureOverviewResponse>(
|
||||
"/api/v1/infrastructure/overview");
|
||||
Assert.NotNull(overview);
|
||||
Assert.Equal(0, overview.ServiceCount);
|
||||
Assert.Equal(0, overview.UnhealthyServiceCount);
|
||||
|
||||
var archivedDetail = await client.GetFromJsonAsync<InfrastructureServiceDetail>(
|
||||
$"/api/v1/infrastructure/services/{proposal.Id}");
|
||||
Assert.NotNull(archivedDetail);
|
||||
Assert.True(archivedDetail.EditorialProfile!.IsArchived);
|
||||
Assert.Equal(proposal.Id, Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true"))!.Items).Id);
|
||||
}
|
||||
|
||||
// V2-R2, docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md, Finding
|
||||
// B: two genuinely concurrent syncs of the *same* snapshot (same SourceHash) — e.g.
|
||||
// a retried automation trigger firing twice — must never surface as an unhandled
|
||||
// 500. The DB's own `ix_inventory_snapshots_workspace_id_source_hash` unique index
|
||||
// is the real source of truth; this proves the loser of the race gets the same
|
||||
// graceful "AlreadyImported" result the sequential retry path already returns
|
||||
// (`A_disappeared_service_is_marked_missing_and_an_identical_snapshot_is_idempotent`
|
||||
// above), not an exception, and that exactly one snapshot row is ever persisted.
|
||||
[Fact]
|
||||
public async Task Two_concurrent_syncs_of_the_identical_snapshot_never_500_and_only_one_snapshot_row_survives()
|
||||
{
|
||||
using var firstClient = Factory.CreateClient();
|
||||
using var secondClient = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(firstClient, OwnerEmail, OwnerPassword);
|
||||
await SetupHelper.LoginAsync(secondClient, OwnerEmail, OwnerPassword);
|
||||
|
||||
var snapshot = Snapshot("hash-concurrent-race", includeService: true, "running");
|
||||
|
||||
var responses = await Task.WhenAll(
|
||||
SetupHelper.PostWithCsrfAsync(firstClient, "/api/v1/infrastructure/inventory/snapshots", snapshot),
|
||||
SetupHelper.PostWithCsrfAsync(secondClient, "/api/v1/infrastructure/inventory/snapshots", snapshot));
|
||||
|
||||
Assert.All(responses, response => Assert.Equal(HttpStatusCode.OK, response.StatusCode));
|
||||
|
||||
var results = await Task.WhenAll(responses.Select(r => r.Content.ReadFromJsonAsync<InventorySyncResponse>()));
|
||||
Assert.All(results, result => Assert.NotNull(result));
|
||||
Assert.Single(results, result => !result!.AlreadyImported);
|
||||
Assert.Single(results, result => result!.AlreadyImported);
|
||||
// Both responses must reference the same real, persisted snapshot — the loser's
|
||||
// graceful fallback resolves the winner's row rather than fabricating a result.
|
||||
Assert.Equal(results[0]!.SnapshotId, results[1]!.SnapshotId);
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var snapshotCount = await db.InventorySnapshots.CountAsync(row => row.SourceHash == "hash-concurrent-race");
|
||||
Assert.Equal(1, snapshotCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stale_editorial_profile_ETag_is_rejected()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-concurrency", includeService: true, "running"));
|
||||
// includeHidden=true, matching every other test in this file that fetches a
|
||||
// freshly-synced (and therefore still-Proposed, unreviewed) service: the
|
||||
// ordinary, non-includeHidden catalog now correctly excludes Proposed/Archived
|
||||
// services (fixed alongside this test as part of the V2-M4 audit — see
|
||||
// InfrastructureRepository.ListServicesAsync's own comment). This test exercises
|
||||
// editorial-profile concurrency, not catalog visibility, so it only needs a
|
||||
// reliable way to find the one service that exists.
|
||||
var service = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
|
||||
|
||||
var request = new UpdateEditorialProfileRequest(
|
||||
"Naam", "Samenvatting", "Other", "Doel", null, null, null, null, null, null,
|
||||
null, null, null, null, [], false, false, false);
|
||||
var first = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", request, "0");
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
|
||||
var stale = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", request, "0");
|
||||
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_administrator_can_accept_a_proposed_service_and_the_decision_survives_sync()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-review-1", includeService: true, "running"));
|
||||
var service = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
|
||||
Assert.Equal("Proposed", service.Presence);
|
||||
|
||||
var review = await SetupHelper.PostWithCsrfAsync(
|
||||
client,
|
||||
$"/api/v1/infrastructure/services/{service.Id}/review",
|
||||
new ReviewInfrastructureServiceRequest("Accept", "Herkenbare productiecomponent."));
|
||||
Assert.Equal(HttpStatusCode.OK, review.StatusCode);
|
||||
var reviewed = await review.Content.ReadFromJsonAsync<ReviewInfrastructureServiceResponse>();
|
||||
Assert.Equal("Present", reviewed!.Presence);
|
||||
|
||||
await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-review-2", includeService: true, "healthy"));
|
||||
var afterSync = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
|
||||
Assert.Equal("Present", afterSync.Presence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_curated_package_creates_editorial_context_and_confirms_validated_services_idempotently()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var inventory = Snapshot("hash-curated-package", includeService: true, "running");
|
||||
var package = new ImportCuratedInventoryRequest(
|
||||
inventory,
|
||||
[new CuratedServiceProfileRequest(
|
||||
"docker:knowledge-hub", "ItWorx Knowledge Hub", "Infrastructure & Project Knowledge OS",
|
||||
"Knowledge OS", "Operationele kennis centraal beheren.",
|
||||
"https://example.test/knowledge-hub", "http://192.0.2.10:1235",
|
||||
["infrastructuur", "productie"], true, false)]);
|
||||
|
||||
var first = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/curated-package", package);
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
var imported = await first.Content.ReadFromJsonAsync<ImportCuratedInventoryResponse>();
|
||||
Assert.NotNull(imported);
|
||||
Assert.Equal(1, imported.CreatedEditorialProfiles);
|
||||
Assert.Equal(1, imported.ConfirmedServices);
|
||||
|
||||
var service = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
|
||||
Assert.Equal("Present", service.Presence);
|
||||
Assert.Equal("ItWorx Knowledge Hub", service.Name);
|
||||
var detail = await client.GetFromJsonAsync<InfrastructureServiceDetail>(
|
||||
$"/api/v1/infrastructure/services/{service.Id}");
|
||||
Assert.Equal("Infrastructure & Project Knowledge OS", detail!.EditorialProfile!.Summary);
|
||||
|
||||
var retry = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/infrastructure/inventory/curated-package", package);
|
||||
Assert.Equal(HttpStatusCode.OK, retry.StatusCode);
|
||||
var retried = await retry.Content.ReadFromJsonAsync<ImportCuratedInventoryResponse>();
|
||||
Assert.True(retried!.Inventory.AlreadyImported);
|
||||
Assert.Equal(0, retried.CreatedEditorialProfiles);
|
||||
Assert.Equal(0, retried.ConfirmedServices);
|
||||
}
|
||||
|
||||
// --- Authorization boundaries (V2-M4: "make source-managed facts, editorial
|
||||
// profiles and manual overrides ... safely editable" implies the write paths must
|
||||
// actually be gated, not just modeled). RequireContentAccess(role, scope) is
|
||||
// already exercised generically elsewhere (M6's ServiceAccountTests,
|
||||
// RoleAuthorizationTests), but per CLAUDE.md's testing standard ("add integration
|
||||
// tests for every persistence or authorization boundary") and the V2-M4 test gate
|
||||
// explicitly naming infrastructure:read/write, each protected route group here
|
||||
// gets its own boundary proof rather than relying solely on the shared mechanism
|
||||
// being correct in general.
|
||||
|
||||
[Fact]
|
||||
public async Task Unauthenticated_requests_to_infrastructure_endpoints_are_rejected()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
|
||||
using var readResponse = await client.GetAsync("/api/v1/infrastructure/overview");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, readResponse.StatusCode);
|
||||
|
||||
using var writeResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-unauth", includeService: false, "running"));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, writeResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reader_can_read_infrastructure_but_cannot_write_facts_overrides_or_trigger_a_sync()
|
||||
{
|
||||
using var adminClient = await AuthenticatedClientAsync();
|
||||
await SetupHelper.PostWithCsrfAsync(
|
||||
adminClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-reader", includeService: true, "running"));
|
||||
var service = Assert.Single((await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
|
||||
|
||||
using var readerClient = await RoleClientAsync(adminClient, "lezer-infra@itworx.test", "reader-strong-password", "Reader");
|
||||
|
||||
using var overview = await readerClient.GetAsync("/api/v1/infrastructure/overview");
|
||||
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
|
||||
|
||||
var profileRequest = new UpdateEditorialProfileRequest(
|
||||
"Naam", "Samenvatting", "Other", "Doel", null, null, null, null, null, null,
|
||||
null, null, null, null, [], false, false, true);
|
||||
var profileWrite = await SetupHelper.PutWithCsrfAsync(
|
||||
readerClient, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", profileRequest, "0");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, profileWrite.StatusCode);
|
||||
|
||||
var overrideWrite = await SetupHelper.PostWithCsrfAsync(
|
||||
readerClient,
|
||||
$"/api/v1/infrastructure/services/{service.Id}/overrides",
|
||||
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("\"Onterecht\""), "Poging."));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, overrideWrite.StatusCode);
|
||||
|
||||
var syncWrite = await SetupHelper.PostWithCsrfAsync(
|
||||
readerClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-reader-2", includeService: false, "running"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, syncWrite.StatusCode);
|
||||
|
||||
// The Reader's denied attempts must never have taken effect.
|
||||
var stillDefault = await adminClient.GetFromJsonAsync<InfrastructureServiceDetail>(
|
||||
$"/api/v1/infrastructure/services/{service.Id}");
|
||||
Assert.Null(stillDefault!.EditorialProfile);
|
||||
Assert.Empty(stillDefault.Overrides);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_editor_can_edit_facts_and_overrides_but_cannot_review_proposals_or_trigger_a_sync()
|
||||
{
|
||||
using var adminClient = await AuthenticatedClientAsync();
|
||||
await SetupHelper.PostWithCsrfAsync(
|
||||
adminClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-editor", includeService: true, "running"));
|
||||
var service = Assert.Single((await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
|
||||
Assert.Equal("Proposed", service.Presence);
|
||||
|
||||
using var editorClient = await RoleClientAsync(adminClient, "redacteur-infra@itworx.test", "editor-strong-password", "Editor");
|
||||
|
||||
var profileRequest = new UpdateEditorialProfileRequest(
|
||||
"Naam", "Samenvatting", "Other", "Doel", null, null, null, null, null, null,
|
||||
null, null, null, null, [], false, false, false);
|
||||
var profileWrite = await SetupHelper.PutWithCsrfAsync(
|
||||
editorClient, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", profileRequest, "0");
|
||||
Assert.Equal(HttpStatusCode.OK, profileWrite.StatusCode);
|
||||
|
||||
var overrideWrite = await SetupHelper.PostWithCsrfAsync(
|
||||
editorClient,
|
||||
$"/api/v1/infrastructure/services/{service.Id}/overrides",
|
||||
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("\"itworx-knowledge-hub (redacteurcorrectie)\""), "Redacteurcorrectie."));
|
||||
Assert.Equal(HttpStatusCode.OK, overrideWrite.StatusCode);
|
||||
|
||||
// Review (Administrator-only per InfrastructureEndpoints.cs) and triggering a
|
||||
// sync (also Administrator-only) must still be denied to an Editor even though
|
||||
// both are nominally "write" actions — the role check is independent of and
|
||||
// stricter than the scope check.
|
||||
var review = await SetupHelper.PostWithCsrfAsync(
|
||||
editorClient,
|
||||
$"/api/v1/infrastructure/services/{service.Id}/review",
|
||||
new ReviewInfrastructureServiceRequest("Accept", "Poging door redacteur."));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, review.StatusCode);
|
||||
|
||||
var syncWrite = await SetupHelper.PostWithCsrfAsync(
|
||||
editorClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-editor-2", includeService: false, "running"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, syncWrite.StatusCode);
|
||||
|
||||
var stillProposed = await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true");
|
||||
Assert.Equal("Proposed", Assert.Single(stillProposed!.Items).Presence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_bearer_token_with_infrastructure_read_can_read_but_not_write_horizontal_and_vertical_escalation()
|
||||
{
|
||||
using var adminClient = await AuthenticatedClientAsync();
|
||||
await SetupHelper.PostWithCsrfAsync(
|
||||
adminClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-bearer-read", includeService: true, "running"));
|
||||
var service = Assert.Single((await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
|
||||
|
||||
var created = await CreateServiceAccountAsync(adminClient, "Infra-read-bot", Scopes.InfrastructureRead);
|
||||
using var bearerClient = BearerClient(created.RawToken);
|
||||
|
||||
using var readResponse = await bearerClient.GetAsync("/api/v1/infrastructure/overview");
|
||||
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
|
||||
|
||||
// infrastructure:read must not implicitly grant infrastructure:write — a
|
||||
// vertical privilege-escalation attempt, matching M6's established pattern for
|
||||
// every other scoped resource in this API.
|
||||
var overrideWrite = await bearerClient.PostAsJsonAsync(
|
||||
$"/api/v1/infrastructure/services/{service.Id}/overrides",
|
||||
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("\"Onterecht\""), "Bearer-poging."));
|
||||
Assert.True(
|
||||
overrideWrite.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
|
||||
$"Expected the write attempt to be denied, but got {overrideWrite.StatusCode}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_bearer_token_without_any_infrastructure_scope_cannot_read_or_write_horizontal_escalation()
|
||||
{
|
||||
using var adminClient = await AuthenticatedClientAsync();
|
||||
// A token scoped to a completely unrelated resource — never infrastructure:read
|
||||
// or infrastructure:write. Proves scopes for one resource never leak access to
|
||||
// another (horizontal privilege escalation), matching ServiceAccountTests'
|
||||
// "pages:write does not grant project access" precedent for this resource.
|
||||
var created = await CreateServiceAccountAsync(adminClient, "Search-only-bot", Scopes.KnowledgeSearch);
|
||||
using var bearerClient = BearerClient(created.RawToken);
|
||||
|
||||
using var readResponse = await bearerClient.GetAsync("/api/v1/infrastructure/overview");
|
||||
Assert.True(
|
||||
readResponse.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
|
||||
$"Expected the read attempt to be denied, but got {readResponse.StatusCode}.");
|
||||
}
|
||||
|
||||
private async Task<HttpClient> RoleClientAsync(HttpClient adminClient, string email, string password, string role)
|
||||
{
|
||||
var createResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
adminClient, "/api/v1/admin/users", new CreateUserRequest($"Test {role}", email, password, role));
|
||||
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
|
||||
|
||||
var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, email, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<CreatedServiceAccountToken> CreateServiceAccountAsync(HttpClient adminClient, string name, params string[] scopes)
|
||||
{
|
||||
var response = await SetupHelper.PostWithCsrfAsync(
|
||||
adminClient, "/api/v1/service-accounts", new CreateServiceAccountRequest(name, "Testaccount.", scopes));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var created = await response.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
|
||||
Assert.NotNull(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private HttpClient BearerClient(string rawToken)
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private async Task<HttpClient> AuthenticatedClientAsync()
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static SynchronizeInventoryRequest Snapshot(
|
||||
string hash, bool includeService, string runtimeStatus, string health = "Healthy")
|
||||
{
|
||||
var services = includeService
|
||||
? new[]
|
||||
{
|
||||
new InventoryServiceRequest(
|
||||
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", runtimeStatus,
|
||||
health, "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
|
||||
[new InventoryComponentRequest("container:app", "app", "container", "abc123", runtimeStatus, "itworx/knowledge-hub:v2", "High")],
|
||||
[new InventoryEndpointRequest("port:1235", "http", "192.0.2.10", 1235, 8080, "http://192.0.2.10:1235", false, "High")],
|
||||
[new InventoryNetworkRequest("network:internal", "knowledge-internal", "bridge", "local", true, "High")],
|
||||
[new InventoryVolumeRequest("volume:attachments", "attachments", "/app/attachments", "appdata", false, 1024, "High")],
|
||||
[new InventoryDatabaseRequest("database:postgres", "knowledge_hub", "postgresql", "17", "db", 5432, false, "High")],
|
||||
[], [new InventoryHealthRequest("health:app", health, "container", 5, "High")],
|
||||
[new InventoryDeploymentRequest("deployment:app", "https://example.test/repo", "abc", "itworx/knowledge-hub:v2", "sha256:example", null, "High")],
|
||||
[new InventoryBackupRequest("backup:daily", "pg_dump", "daily", DateTimeOffset.UtcNow.AddHours(-1), true, "High")])
|
||||
}
|
||||
: [];
|
||||
|
||||
return new SynchronizeInventoryRequest(
|
||||
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
|
||||
services);
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
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",
|
||||
[], [], [], [], [], [], [], [], [])]);
|
||||
}
|
||||
Reference in new issue
Block a user