fix(ui): preserve compose draft identity and delivery safeguards
This commit is contained in:
@@ -115,7 +115,7 @@ public sealed class EmailDraftsControllerTests
|
||||
|
||||
var refused = await controller.NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(1), default);
|
||||
Assert.IsType<ConflictObjectResult>(refused.Result);
|
||||
db.EmailSendAttempts.Add(new EmailSendAttempt
|
||||
var failedAttempt = new EmailSendAttempt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = "user-1",
|
||||
@@ -127,7 +127,8 @@ public sealed class EmailDraftsControllerTests
|
||||
FailureCategory = "provider_rejected",
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
CompletedAtUtc = DateTime.UtcNow,
|
||||
});
|
||||
};
|
||||
db.EmailSendAttempts.Add(failedAttempt);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var rotatedResult = await controller.NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(1), default);
|
||||
@@ -139,6 +140,218 @@ public sealed class EmailDraftsControllerTests
|
||||
Assert.IsType<ConflictObjectResult>((await controller.NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(1), default)).Result);
|
||||
await using var otherDb = fixture.Context("user-2");
|
||||
Assert.IsType<NotFoundResult>((await Controller(otherDb, "user-2").NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(2), default)).Result);
|
||||
|
||||
await using var verify = fixture.Context("user-1");
|
||||
var persisted = await verify.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
AssertDraftContentUnchanged(original, persisted);
|
||||
Assert.Equal(rotated.ClientRequestId, persisted.ClientRequestId);
|
||||
Assert.Equal(2, persisted.Revision);
|
||||
var readback = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await Controller(verify, "user-1").Get(original.Id, default)).Result).Value);
|
||||
Assert.Equal(rotated, readback);
|
||||
AssertAttemptUnchanged(failedAttempt, Assert.Single(await verify.EmailSendAttempts.AsNoTracking().ToListAsync()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EmailSendStatuses.Pending)]
|
||||
[InlineData(EmailSendStatuses.Sending)]
|
||||
[InlineData(EmailSendStatuses.Uncertain)]
|
||||
[InlineData(EmailSendStatuses.Sent)]
|
||||
public async Task Nonfailed_attempts_cannot_rotate_identity_or_change_draft_and_history(string status)
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(seedDrafts: true);
|
||||
await using var db = fixture.Context("user-1");
|
||||
var original = await db.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
var attempt = new EmailSendAttempt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = original.OwnerUserId,
|
||||
JobApplicationId = original.JobApplicationId,
|
||||
Provider = original.Provider,
|
||||
ClientRequestId = original.ClientRequestId,
|
||||
PayloadHash = new string('a', 64),
|
||||
Status = status,
|
||||
ProviderMessageId = status == EmailSendStatuses.Sent ? "sent-message" : null,
|
||||
FailureCategory = status == EmailSendStatuses.Uncertain ? "delivery_unknown" : null,
|
||||
CreatedAtUtc = original.CreatedAtUtc,
|
||||
StartedAtUtc = status == EmailSendStatuses.Pending ? null : original.UpdatedAtUtc,
|
||||
CompletedAtUtc = status is EmailSendStatuses.Uncertain or EmailSendStatuses.Sent ? original.UpdatedAtUtc : null,
|
||||
};
|
||||
db.EmailSendAttempts.Add(attempt);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await Controller(db, "user-1").NewAttempt(original.Id,
|
||||
new EmailDraftsController.NewAttemptRequest(original.Revision), default);
|
||||
Assert.IsType<ConflictObjectResult>(result.Result);
|
||||
|
||||
await using var verify = fixture.Context("user-1");
|
||||
var persisted = await verify.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
AssertDraftContentUnchanged(original, persisted);
|
||||
Assert.Equal(original.ClientRequestId, persisted.ClientRequestId);
|
||||
Assert.Equal(original.Revision, persisted.Revision);
|
||||
Assert.Equal(original.UpdatedAtUtc, persisted.UpdatedAtUtc);
|
||||
AssertAttemptUnchanged(attempt, Assert.Single(await verify.EmailSendAttempts.AsNoTracking().ToListAsync()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Failures_with_another_owner_or_request_key_cannot_rotate_or_change_either_tenant(bool wrongOwner)
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(seedDrafts: true);
|
||||
await using var seed = fixture.Context(null);
|
||||
var originals = await seed.EmailDrafts.IgnoreQueryFilters().AsNoTracking().OrderBy(draft => draft.OwnerUserId).ToListAsync();
|
||||
var ownDraft = originals.Single(draft => draft.OwnerUserId == "user-1");
|
||||
var foreignDraft = originals.Single(draft => draft.OwnerUserId == "user-2");
|
||||
var attempt = new EmailSendAttempt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = wrongOwner ? foreignDraft.OwnerUserId : ownDraft.OwnerUserId,
|
||||
JobApplicationId = wrongOwner ? foreignDraft.JobApplicationId : ownDraft.JobApplicationId,
|
||||
Provider = ownDraft.Provider,
|
||||
ClientRequestId = wrongOwner ? ownDraft.ClientRequestId : foreignDraft.ClientRequestId,
|
||||
PayloadHash = new string('b', 64),
|
||||
Status = EmailSendStatuses.Failed,
|
||||
FailureCategory = "provider_rejected",
|
||||
CreatedAtUtc = ownDraft.CreatedAtUtc,
|
||||
StartedAtUtc = ownDraft.UpdatedAtUtc,
|
||||
CompletedAtUtc = ownDraft.UpdatedAtUtc,
|
||||
};
|
||||
seed.EmailSendAttempts.Add(attempt);
|
||||
await seed.SaveChangesAsync();
|
||||
|
||||
await using var db = fixture.Context("user-1");
|
||||
var result = await Controller(db, "user-1").NewAttempt(ownDraft.Id,
|
||||
new EmailDraftsController.NewAttemptRequest(ownDraft.Revision), default);
|
||||
Assert.IsType<ConflictObjectResult>(result.Result);
|
||||
|
||||
await using var verify = fixture.Context(null);
|
||||
var persistedDrafts = await verify.EmailDrafts.IgnoreQueryFilters().AsNoTracking().OrderBy(draft => draft.OwnerUserId).ToListAsync();
|
||||
Assert.Equal(originals.Count, persistedDrafts.Count);
|
||||
foreach (var original in originals)
|
||||
{
|
||||
var persisted = persistedDrafts.Single(draft => draft.Id == original.Id);
|
||||
AssertDraftContentUnchanged(original, persisted);
|
||||
Assert.Equal(original.ClientRequestId, persisted.ClientRequestId);
|
||||
Assert.Equal(original.Revision, persisted.Revision);
|
||||
Assert.Equal(original.UpdatedAtUtc, persisted.UpdatedAtUtc);
|
||||
}
|
||||
AssertAttemptUnchanged(attempt, Assert.Single(await verify.EmailSendAttempts.IgnoreQueryFilters().AsNoTracking().ToListAsync()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_DTO_timestamps_keep_UTC_shape_and_exact_ticks_after_SQLite_roundtrips()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var createDb = fixture.Context("user-1");
|
||||
var created = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<CreatedAtActionResult>((await Controller(createDb, "user-1").Create(
|
||||
new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "gmail", "one@example.test", "UTC subject", "UTC body", "utc-thread"), default)).Result).Value);
|
||||
AssertUtcTimestamps(created);
|
||||
|
||||
await using var readDb = fixture.Context("user-1");
|
||||
var beforeReads = await readDb.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
var reader = Controller(readDb, "user-1");
|
||||
var retrieved = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await reader.Get(created.Id, default)).Result).Value);
|
||||
var listed = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<EmailDraftsController.DraftDto>>(
|
||||
Assert.IsType<OkObjectResult>((await reader.List(fixture.UserOneJobId, default)).Result).Value));
|
||||
|
||||
await using var verifyReads = fixture.Context("user-1");
|
||||
var afterReads = await verifyReads.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
AssertDraftContentUnchanged(beforeReads, afterReads);
|
||||
Assert.Equal(beforeReads.ClientRequestId, afterReads.ClientRequestId);
|
||||
Assert.Equal(beforeReads.Revision, afterReads.Revision);
|
||||
Assert.Equal(beforeReads.UpdatedAtUtc, afterReads.UpdatedAtUtc);
|
||||
Assert.Equal(beforeReads.CreatedAtUtc.Kind, afterReads.CreatedAtUtc.Kind);
|
||||
Assert.Equal(beforeReads.UpdatedAtUtc.Kind, afterReads.UpdatedAtUtc.Kind);
|
||||
Assert.Empty(await verifyReads.EmailSendAttempts.AsNoTracking().ToListAsync());
|
||||
Assert.Equal(created, retrieved);
|
||||
Assert.Equal(created, listed);
|
||||
Assert.Equal(created.CreatedAtUtc.Ticks, retrieved.CreatedAtUtc.Ticks);
|
||||
Assert.Equal(created.UpdatedAtUtc.Ticks, retrieved.UpdatedAtUtc.Ticks);
|
||||
Assert.Equal(created.CreatedAtUtc.Ticks, listed.CreatedAtUtc.Ticks);
|
||||
Assert.Equal(created.UpdatedAtUtc.Ticks, listed.UpdatedAtUtc.Ticks);
|
||||
AssertUtcTimestamps(retrieved);
|
||||
AssertUtcTimestamps(listed);
|
||||
|
||||
var updated = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await reader.Update(created.Id,
|
||||
new EmailDraftsController.UpdateDraftRequest(created.Revision, "one@example.test", "Updated UTC subject", "Updated UTC body"), default)).Result).Value);
|
||||
AssertUtcTimestamps(updated);
|
||||
Assert.Equal(created.CreatedAtUtc.Ticks, updated.CreatedAtUtc.Ticks);
|
||||
Assert.Equal(created.ClientRequestId, updated.ClientRequestId);
|
||||
Assert.Equal(2, updated.Revision);
|
||||
|
||||
var failedAttempt = new EmailSendAttempt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = "user-1",
|
||||
JobApplicationId = updated.JobApplicationId,
|
||||
Provider = updated.Provider,
|
||||
ClientRequestId = updated.ClientRequestId,
|
||||
PayloadHash = new string('c', 64),
|
||||
Status = EmailSendStatuses.Failed,
|
||||
FailureCategory = "provider_rejected",
|
||||
CreatedAtUtc = updated.CreatedAtUtc,
|
||||
CompletedAtUtc = updated.UpdatedAtUtc,
|
||||
};
|
||||
readDb.EmailSendAttempts.Add(failedAttempt);
|
||||
await readDb.SaveChangesAsync();
|
||||
var rotated = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await reader.NewAttempt(updated.Id,
|
||||
new EmailDraftsController.NewAttemptRequest(updated.Revision), default)).Result).Value);
|
||||
AssertUtcTimestamps(rotated);
|
||||
Assert.Equal(updated.CreatedAtUtc.Ticks, rotated.CreatedAtUtc.Ticks);
|
||||
Assert.NotEqual(updated.ClientRequestId, rotated.ClientRequestId);
|
||||
Assert.Equal(3, rotated.Revision);
|
||||
|
||||
await using var verifyRotation = fixture.Context("user-1");
|
||||
var finalReader = Controller(verifyRotation, "user-1");
|
||||
var finalRetrieved = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await finalReader.Get(rotated.Id, default)).Result).Value);
|
||||
var finalListed = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<EmailDraftsController.DraftDto>>(
|
||||
Assert.IsType<OkObjectResult>((await finalReader.List(null, default)).Result).Value));
|
||||
Assert.Equal(rotated, finalRetrieved);
|
||||
Assert.Equal(rotated, finalListed);
|
||||
AssertUtcTimestamps(finalRetrieved);
|
||||
AssertUtcTimestamps(finalListed);
|
||||
AssertAttemptUnchanged(failedAttempt, Assert.Single(await verifyRotation.EmailSendAttempts.AsNoTracking().ToListAsync()));
|
||||
}
|
||||
|
||||
private static void AssertUtcTimestamps(EmailDraftsController.DraftDto draft)
|
||||
{
|
||||
Assert.Equal(DateTimeKind.Utc, draft.CreatedAtUtc.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, draft.UpdatedAtUtc.Kind);
|
||||
}
|
||||
|
||||
private static void AssertDraftContentUnchanged(EmailDraft expected, EmailDraft actual)
|
||||
{
|
||||
Assert.Equal(expected.Id, actual.Id);
|
||||
Assert.Equal(expected.OwnerUserId, actual.OwnerUserId);
|
||||
Assert.Equal(expected.JobApplicationId, actual.JobApplicationId);
|
||||
Assert.Equal(expected.Provider, actual.Provider);
|
||||
Assert.Equal(expected.To, actual.To);
|
||||
Assert.Equal(expected.Subject, actual.Subject);
|
||||
Assert.Equal(expected.BodyText, actual.BodyText);
|
||||
Assert.Equal(expected.ThreadId, actual.ThreadId);
|
||||
Assert.Equal(expected.CreatedAtUtc, actual.CreatedAtUtc);
|
||||
}
|
||||
|
||||
private static void AssertAttemptUnchanged(EmailSendAttempt expected, EmailSendAttempt actual)
|
||||
{
|
||||
Assert.Equal(expected.Id, actual.Id);
|
||||
Assert.Equal(expected.OwnerUserId, actual.OwnerUserId);
|
||||
Assert.Equal(expected.JobApplicationId, actual.JobApplicationId);
|
||||
Assert.Equal(expected.Provider, actual.Provider);
|
||||
Assert.Equal(expected.ClientRequestId, actual.ClientRequestId);
|
||||
Assert.Equal(expected.PayloadHash, actual.PayloadHash);
|
||||
Assert.Equal(expected.Status, actual.Status);
|
||||
Assert.Equal(expected.ProviderMessageId, actual.ProviderMessageId);
|
||||
Assert.Equal(expected.FailureCategory, actual.FailureCategory);
|
||||
Assert.Equal(expected.CreatedAtUtc, actual.CreatedAtUtc);
|
||||
Assert.Equal(expected.StartedAtUtc, actual.StartedAtUtc);
|
||||
Assert.Equal(expected.CompletedAtUtc, actual.CompletedAtUtc);
|
||||
}
|
||||
|
||||
private static EmailDraftsController Controller(JobTrackerContext db, string userId)
|
||||
@@ -232,6 +445,7 @@ public sealed class EmailDraftsControllerTests
|
||||
To = recipient,
|
||||
Subject = "Synthetic",
|
||||
BodyText = "Synthetic private draft.",
|
||||
ThreadId = "thread-1",
|
||||
ClientRequestId = Guid.NewGuid().ToString("D"),
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
UpdatedAtUtc = DateTime.UtcNow,
|
||||
|
||||
Reference in New Issue
Block a user