using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Admin; using ItWorx.KnowledgeHub.Contracts.Ingestion; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion; [Collection(PostgresTestGroup.Name)] public sealed class IngestionProposalPrivacyTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres, new Dictionary { ["IngestionProposal:Enabled"] = "true" }) { private const string EditorPassword = "strong-editor-password"; private const string ReaderPassword = "strong-reader-password"; [Fact] public async Task Proposal_details_changes_and_source_bytes_follow_submitter_and_review_roles() { using var administrator = Factory.CreateClient(); await SetupHelper.LoginAsync(administrator, OwnerEmail, OwnerPassword); using var submitter = await CreateUserAsync(administrator, "indiener@itworx.test", "Editor", EditorPassword); using var otherEditor = await CreateUserAsync(administrator, "reviewer@itworx.test", "Editor", EditorPassword); using var reader = await CreateUserAsync(administrator, "lezer@itworx.test", "Reader", ReaderPassword); var created = await CreateProposalAsync(submitter); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(submitter, proposal.Id)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(administrator, proposal.Id)).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await GetProposalAsync(otherEditor, proposal.Id)).StatusCode); var submitterReceivedQueue = await submitter.GetFromJsonAsync("/api/v1/ingestion-proposals?status=Received"); Assert.Equal(proposal.Id, Assert.Single(submitterReceivedQueue!.Items).Id); var administratorReceivedQueue = await administrator.GetFromJsonAsync("/api/v1/ingestion-proposals?status=Received"); Assert.Equal(proposal.Id, Assert.Single(administratorReceivedQueue!.Items).Id); var foreignReceivedQueue = await otherEditor.GetFromJsonAsync("/api/v1/ingestion-proposals?status=Received"); Assert.Empty(foreignReceivedQueue!.Items); Assert.Equal(0, foreignReceivedQueue.Total); using (var foreignPatch = await PatchProposalAsync(otherEditor, proposal.Id, created.Headers.ETag!.Tag, "not allowed")) Assert.Equal(HttpStatusCode.NotFound, foreignPatch.StatusCode); using var ownerPatch = await PatchProposalAsync(submitter, proposal.Id, created.Headers.ETag.Tag, "owner edit"); Assert.Equal(HttpStatusCode.OK, ownerPatch.StatusCode); using var adminPatch = await PatchProposalAsync(administrator, proposal.Id, ownerPatch.Headers.ETag!.Tag, "admin edit"); Assert.Equal(HttpStatusCode.OK, adminPatch.StatusCode); var staged = await UploadSourceAsync(submitter, proposal.Id, adminPatch.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.OK, staged.StatusCode); var stagedDetail = await staged.Content.ReadFromJsonAsync(); var source = Assert.Single(stagedDetail!.Sources); var sourceUrl = $"/api/v1/attachments/{source.AttachmentReferenceId}/content"; // A received proposal and its original bytes remain private to the submitter // and administrators, even from another workspace Editor. Assert.Equal(HttpStatusCode.OK, (await submitter.GetAsync(sourceUrl)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await administrator.GetAsync(sourceUrl)).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await otherEditor.GetAsync(sourceUrl)).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await reader.GetAsync(sourceUrl)).StatusCode); using var submitted = await SubmitAsync(submitter, proposal.Id, staged.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.OK, submitted.StatusCode); var ready = await submitted.Content.ReadFromJsonAsync(); Assert.Equal("ReadyForReview", ready?.Status); // During review, workspace Editors retain the established reviewer access. Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(otherEditor, proposal.Id)).StatusCode); var reviewerQueue = await otherEditor.GetFromJsonAsync("/api/v1/ingestion-proposals?status=ReadyForReview"); Assert.Contains(reviewerQueue!.Items, item => item.Id == proposal.Id); Assert.Equal(HttpStatusCode.OK, (await otherEditor.GetAsync(sourceUrl)).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await reader.GetAsync(sourceUrl)).StatusCode); using var reviewerPatch = await PatchProposalAsync(otherEditor, proposal.Id, submitted.Headers.ETag!.Tag, "reviewer edit"); Assert.Equal(HttpStatusCode.OK, reviewerPatch.StatusCode); using var changesRequested = await RequestChangesAsync(otherEditor, proposal.Id, reviewerPatch.Headers.ETag!.Tag); Assert.Equal(HttpStatusCode.OK, changesRequested.StatusCode); var changed = await changesRequested.Content.ReadFromJsonAsync(); Assert.Equal("ChangesRequested", changed?.Status); // Once returned for changes, proposal metadata and source bytes are private // to the submitter and administrators again. Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(submitter, proposal.Id)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await GetProposalAsync(administrator, proposal.Id)).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await GetProposalAsync(otherEditor, proposal.Id)).StatusCode); var submitterChangesQueue = await submitter.GetFromJsonAsync("/api/v1/ingestion-proposals?status=ChangesRequested"); Assert.Contains(submitterChangesQueue!.Items, item => item.Id == proposal.Id); var administratorChangesQueue = await administrator.GetFromJsonAsync("/api/v1/ingestion-proposals?status=ChangesRequested"); Assert.Contains(administratorChangesQueue!.Items, item => item.Id == proposal.Id); var foreignChangesQueue = await otherEditor.GetFromJsonAsync("/api/v1/ingestion-proposals?status=ChangesRequested"); Assert.DoesNotContain(foreignChangesQueue!.Items, item => item.Id == proposal.Id); Assert.Equal(0, foreignChangesQueue.Total); Assert.Equal(HttpStatusCode.NotFound, (await otherEditor.GetAsync(sourceUrl)).StatusCode); using (var foreignChangesPatch = await PatchProposalAsync(otherEditor, proposal.Id, changesRequested.Headers.ETag!.Tag, "not allowed")) Assert.Equal(HttpStatusCode.NotFound, foreignChangesPatch.StatusCode); using var submitterChangesPatch = await PatchProposalAsync(submitter, proposal.Id, changesRequested.Headers.ETag.Tag, "submitter update"); Assert.Equal(HttpStatusCode.OK, submitterChangesPatch.StatusCode); using var adminChangesPatch = await PatchProposalAsync(administrator, proposal.Id, submitterChangesPatch.Headers.ETag!.Tag, "admin update"); Assert.Equal(HttpStatusCode.OK, adminChangesPatch.StatusCode); Assert.Equal(HttpStatusCode.OK, (await administrator.GetAsync(sourceUrl)).StatusCode); } private async Task CreateUserAsync(HttpClient administrator, string email, string role, string password) { var response = await SetupHelper.PostWithCsrfAsync(administrator, "/api/v1/admin/users", new CreateUserRequest(email, email, password, role)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, email, password); return client; } private static async Task CreateProposalAsync(HttpClient client) { var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest( "Private source manifest", Guid.NewGuid().ToString(), new IngestionPublicationRequest("Private source", "Source summary", "Reference", "Draft", "CreatePage"))) }; request.Headers.Add(csrf.HeaderName, csrf.Value); request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); return await client.SendAsync(request); } private static Task GetProposalAsync(HttpClient client, Guid proposalId) => client.GetAsync($"/api/v1/ingestion-proposals/{proposalId}"); private static async Task PatchProposalAsync(HttpClient client, Guid proposalId, string etag, string manifest) { var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/ingestion-proposals/{proposalId}") { Content = JsonContent.Create(new PatchIngestionProposalRequest(manifest, null)) }; request.Headers.Add(csrf.HeaderName, csrf.Value); request.Headers.TryAddWithoutValidation("If-Match", etag); return await client.SendAsync(request); } private static async Task UploadSourceAsync(HttpClient client, Guid proposalId, string etag) { var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var form = new MultipartFormDataContent(); var source = new ByteArrayContent("unpublished document bytes"u8.ToArray()); source.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); form.Add(source, "file", "private-source.txt"); using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposalId}/sources") { Content = form }; request.Headers.Add(csrf.HeaderName, csrf.Value); request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); request.Headers.TryAddWithoutValidation("If-Match", etag); return await client.SendAsync(request); } private static async Task SubmitAsync(HttpClient client, Guid proposalId, string etag) { var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposalId}/submit") { Content = JsonContent.Create(new { }) }; request.Headers.Add(csrf.HeaderName, csrf.Value); request.Headers.TryAddWithoutValidation("If-Match", etag); return await client.SendAsync(request); } private static async Task RequestChangesAsync(HttpClient client, Guid proposalId, string etag) { var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposalId}/request-changes") { Content = JsonContent.Create(new IngestionProposalDecisionRequest("Please revise.")) }; request.Headers.Add(csrf.HeaderName, csrf.Value); request.Headers.TryAddWithoutValidation("If-Match", etag); return await client.SendAsync(request); } }