250 lines
11 KiB
C#
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",
|
|
[], [], [], [], [], [], [], [], [])]);
|
|
}
|