using System.Security.Cryptography;
using ItWorx.KnowledgeHub.Application.Attachments;
using ItWorx.KnowledgeHub.Application.Attachments.UseCases;
using ItWorx.KnowledgeHub.Application.Audit;
using ItWorx.KnowledgeHub.Application.Ingestion;
using ItWorx.KnowledgeHub.Application.Ingestion.UseCases;
using ItWorx.KnowledgeHub.Domain.Attachments;
using ItWorx.KnowledgeHub.Domain.Ingestion;
namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion;
///
/// Does not use the PostgreSQL fixture: the repository port deliberately provides a
/// controllable save-failure seam around the real upload pipeline. It models the
/// database rollback that happens when the shared DbContext save fails after the
/// physical blob commit.
///
public sealed class IngestionProposalSourceCompensationTests
{
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid ActorId = Guid.Parse("22222222-2222-2222-2222-222222222222");
[Fact]
public async Task Failed_shared_database_save_after_a_new_blob_commit_compensates_every_staged_artifact()
{
var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "compensation", "safe manifest");
var attachments = new TransactionalAttachmentRepository();
var audits = new TransactionalAuditRecorder();
var proposals = new FailingSaveIngestionProposalRepository(proposal, attachments, audits);
var blobs = new MemoryBlobStore();
var uploads = new UploadAttachmentUseCase(
pages: null!,
stagingSessions: null!,
ingestionProposals: proposals,
projects: null!,
attachments,
blobs,
new PlainTextSignatureDetector(),
audits,
new AttachmentContentLock(),
TimeProvider.System);
var useCase = new StageIngestionProposalSourceUseCase(
proposals,
attachments,
blobs,
uploads,
audits,
new AttachmentContentLock(),
TimeProvider.System);
var content = "post-blob save failure"u8.ToArray();
var digest = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
await using var stream = new MemoryStream(content);
await Assert.ThrowsAsync(() => useCase.ExecuteAsync(
new StageIngestionProposalSourceCommand(
WorkspaceId,
proposal.Id,
ActorId,
"evidence.txt",
"text/plain",
stream,
"compensation-test",
MaximumSourceCount: 5,
ExpectedETag: "1",
AuditActorKind.ServiceAccount,
AuditSource: "api"),
CancellationToken.None));
Assert.Equal(1, proposals.SaveAttempts);
Assert.Equal(1, proposals.PendingSourceCountAtFailedSave);
Assert.Equal(0, proposals.PersistedSourceCount);
Assert.Empty(attachments.Blobs);
Assert.Empty(attachments.References);
Assert.Empty(attachments.Usages);
Assert.Empty(audits.Events);
Assert.False(blobs.Exists(digest));
Assert.Equal([digest], blobs.DeletedHashes);
}
private sealed class FailingSaveIngestionProposalRepository(
IngestionProposal proposal,
TransactionalAttachmentRepository attachments,
TransactionalAuditRecorder audits) : IIngestionProposalRepository
{
public int SaveAttempts { get; private set; }
public int PendingSourceCountAtFailedSave { get; private set; }
// This is the durable snapshot; it changes only after a successful shared
// save, exactly like a fresh DbContext query after the database rollback.
public int PersistedSourceCount { get; private set; }
public Task ListAsync(IngestionProposalQuery query, CancellationToken cancellationToken) =>
Task.FromResult(new IngestionProposalPage([], null));
public Task FindAsync(Guid workspaceId, Guid proposalId, CancellationToken cancellationToken) =>
Task.FromResult(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id ? proposal : null);
public Task FindByIdempotencyKeyAsync(Guid workspaceId, string idempotencyKey, CancellationToken cancellationToken) =>
Task.FromResult(null);
public Task GetETagAsync(Guid workspaceId, Guid proposalId, CancellationToken cancellationToken) =>
Task.FromResult(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id ? "1" : null);
public Task FindForUpdateAsync(Guid workspaceId, Guid proposalId, string expectedETag, CancellationToken cancellationToken) =>
Task.FromResult(workspaceId == proposal.WorkspaceId && proposalId == proposal.Id && expectedETag == "1" ? proposal : null);
public void Add(IngestionProposal item) => throw new NotSupportedException();
public Task SaveChangesAsync(CancellationToken cancellationToken)
{
SaveAttempts += 1;
PendingSourceCountAtFailedSave = proposal.Sources.Count;
attachments.RollBack();
audits.RollBack();
return Task.FromException(new InvalidOperationException("Forced shared DbContext save failure."));
}
}
private sealed class TransactionalAttachmentRepository : IAttachmentRepository
{
private readonly List blobs = [];
private readonly List references = [];
private readonly List usages = [];
public IReadOnlyList Blobs => blobs;
public IReadOnlyList References => references;
public IReadOnlyList Usages => usages;
public Task FindBlobAsync(string sha256, CancellationToken cancellationToken) =>
Task.FromResult(blobs.SingleOrDefault(blob => blob.Sha256 == sha256));
public void AddBlob(AttachmentBlob blob) => blobs.Add(blob);
public Task FindReferenceAsync(Guid referenceId, CancellationToken cancellationToken) =>
Task.FromResult(references.SingleOrDefault(reference => reference.Id == referenceId));
public void AddReference(AttachmentReference reference) => references.Add(reference);
public void AddUsage(AttachmentUsage usage) => usages.Add(usage);
public Task> GetReferencesForPageAsync(Guid pageId, CancellationToken cancellationToken) =>
Task.FromResult>([]);
public Task> FindBlobsAsync(IReadOnlyCollection sha256Hashes, CancellationToken cancellationToken) =>
Task.FromResult>(new Dictionary());
public Task FindUsageForReferenceAsync(Guid attachmentReferenceId, CancellationToken cancellationToken) =>
Task.FromResult(usages.SingleOrDefault(usage => usage.AttachmentReferenceId == attachmentReferenceId));
public Task> FindUnreferencedBlobHashesAsync(IReadOnlyCollection candidateHashes, CancellationToken cancellationToken) =>
Task.FromResult>(candidateHashes.Where(hash => references.All(reference => reference.BlobSha256 != hash)).ToArray());
public Task RemoveBlobRecordsAsync(IReadOnlyCollection sha256Hashes, CancellationToken cancellationToken) =>
Task.FromResult(0);
public Task> GetUsagesForStagingSessionAsync(Guid stagingSessionId, CancellationToken cancellationToken) =>
Task.FromResult>([]);
public Task TransferProposalUsagesToPageAsync(Guid ingestionProposalId, Guid pageId, CancellationToken cancellationToken)
{
foreach (var usage in usages.Where(usage => usage.IngestionProposalId == ingestionProposalId))
{
usage.TransferProposalToPage(pageId);
}
return Task.CompletedTask;
}
public Task RemoveOrphanedStagingAttachmentsAsync(Guid stagingSessionId, CancellationToken cancellationToken) =>
Task.FromResult(StagingAttachmentCleanupResult.None);
public Task SaveChangesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void RollBack()
{
blobs.Clear();
references.Clear();
usages.Clear();
}
}
private sealed class TransactionalAuditRecorder : IAuditEventRecorder
{
private readonly List events = [];
public IReadOnlyList Events => events;
public void Add(AuditEventRecord auditEvent) => events.Add(auditEvent);
public Task RecordAsync(AuditEventRecord auditEvent, CancellationToken cancellationToken)
{
events.Add(auditEvent);
return Task.CompletedTask;
}
public void RollBack() => events.Clear();
}
private sealed class MemoryBlobStore : IAttachmentBlobStore
{
private readonly Dictionary blobs = new(StringComparer.Ordinal);
private readonly Dictionary quarantined = new(StringComparer.Ordinal);
public IReadOnlyList DeletedHashes => deletedHashes;
private readonly List deletedHashes = [];
public bool Exists(string sha256) => blobs.ContainsKey(sha256);
public async Task WriteToQuarantineAsync(Stream content, CancellationToken cancellationToken)
{
using var bytes = new MemoryStream();
await content.CopyToAsync(bytes, cancellationToken);
var value = bytes.ToArray();
var sha256 = Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
var token = Guid.NewGuid().ToString("N");
quarantined.Add(token, value);
return new QuarantinedUpload(token, sha256, value.LongLength);
}
public Task ReadQuarantinePrefixAsync(string quarantineToken, int maxBytes, CancellationToken cancellationToken) =>
Task.FromResult(quarantined[quarantineToken].Take(maxBytes).ToArray());
public Task BlobExistsAsync(string sha256, CancellationToken cancellationToken) => Task.FromResult(Exists(sha256));
public Task CommitAsync(string quarantineToken, string sha256, CancellationToken cancellationToken)
{
blobs.Add(sha256, quarantined[quarantineToken]);
quarantined.Remove(quarantineToken);
return Task.CompletedTask;
}
public Task DiscardQuarantineAsync(string quarantineToken, CancellationToken cancellationToken)
{
quarantined.Remove(quarantineToken);
return Task.CompletedTask;
}
public Task OpenReadAsync(string sha256, CancellationToken cancellationToken) =>
Task.FromResult(new MemoryStream(blobs[sha256], writable: false));
public Task DeleteVerifiedAsync(string sha256, CancellationToken cancellationToken)
{
var removed = blobs.Remove(sha256);
if (removed)
{
deletedHashes.Add(sha256);
}
return Task.FromResult(removed);
}
}
private sealed class PlainTextSignatureDetector : IContentSignatureDetector
{
public SignatureDetectionResult Detect(ReadOnlySpan prefix, string? declaredContentType, string? extension) =>
new("text/plain", AttachmentDeliveryPolicy.DownloadOnly, DeclaredTypeMismatch: false);
}
}