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

250 lines
11 KiB
C#

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