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; /// /// 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. /// [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( "/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? 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(); Assert.Equal(ProblemTypes.Validation, problem!.Type); } [Theory] [InlineData("javascript:alert(1)")] [InlineData("data:text/html,")] [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(); 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(); 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(); 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(); 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", [], [], [], [], [], [], [], [], [])]); }