using System.Net; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Admin; using ItWorx.KnowledgeHub.Contracts.Knowledge; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge; /// The explicit administrative purge never removes a page still within its /// trash retention window, and is never reachable except by an administrator /// (docs/09-data-safety.md: "never a permanent delete through a normal UI flow"). [Collection(PostgresTestGroup.Name)] public sealed class TrashPurgeTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Purge_does_not_remove_a_trashed_page_still_within_its_retention_window() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var createResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Recent verwijderd", "WikiPage", "Inhoud.", null)); var page = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(page); await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag); var purgeResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/trash/purge", (object?)null); Assert.Equal(HttpStatusCode.OK, purgeResponse.StatusCode); var result = await purgeResponse.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.Equal(0, result.PurgedCount); var stillThere = await client.GetAsync($"/api/v1/pages/{page.Id}"); Assert.Equal(HttpStatusCode.OK, stillThere.StatusCode); } [Fact] public async Task Purge_permanently_removes_a_trashed_page_once_its_retention_window_has_elapsed() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var createResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Lang geleden verwijderd", "WikiPage", "Inhoud.", null)); var page = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(page); await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag); // Directly back-date the retention window rather than waiting 30 real days — // the use case's own eligibility computation (Trash(...) with a fixed // TimeSpan) is exercised elsewhere; this test proves the purge query itself // correctly identifies and removes an eligible row. using (var scope = Factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.ExecuteSqlInterpolatedAsync( $"UPDATE pages SET purge_after = {DateTimeOffset.UtcNow.AddDays(-1)} WHERE id = {page.Id}"); } var purgeResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/trash/purge", (object?)null); Assert.Equal(HttpStatusCode.OK, purgeResponse.StatusCode); var result = await purgeResponse.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.Equal(1, result.PurgedCount); var afterPurge = await client.GetAsync($"/api/v1/pages/{page.Id}"); Assert.Equal(HttpStatusCode.NotFound, afterPurge.StatusCode); } [Fact] public async Task Purge_is_administrator_only() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var editorEmail = "redacteur-purge@itworx.test"; var editorPassword = "correct-horse-battery-staple"; await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/users", new CreateUserRequest("Redacteur", editorEmail, editorPassword, "Editor")); using var editorClient = Factory.CreateClient(); await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword); var response = await SetupHelper.PostWithCsrfAsync(editorClient, "/api/v1/admin/trash/purge", (object?)null); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } }