feat(email): export send attempt metadata
Add content-free provider delivery history to encrypted and daily owner exports, exclude payload hashes, and lock in tenant-safe hard-delete cascades.
This commit is contained in:
@@ -114,6 +114,15 @@ public sealed class BackgroundWorkerTenantTests
|
|||||||
["Data:Root"] = root,
|
["Data:Root"] = root,
|
||||||
});
|
});
|
||||||
await fixture.SeedJobsAsync();
|
await fixture.SeedJobsAsync();
|
||||||
|
await using (var seedScope = fixture.Provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = seedScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||||
|
var jobs = await db.JobApplications.IgnoreQueryFilters().OrderBy(job => job.OwnerUserId).ToListAsync();
|
||||||
|
db.EmailSendAttempts.AddRange(
|
||||||
|
new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = "user-1", JobApplicationId = jobs[0].Id, Provider = "gmail", ClientRequestId = Guid.NewGuid().ToString(), PayloadHash = new string('a', 64), Status = EmailSendStatuses.Sent, CreatedAtUtc = DateTime.UtcNow },
|
||||||
|
new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = "user-2", JobApplicationId = jobs[1].Id, Provider = "microsoft", ClientRequestId = Guid.NewGuid().ToString(), PayloadHash = new string('b', 64), Status = EmailSendStatuses.Failed, FailureCategory = "rejected", CreatedAtUtc = DateTime.UtcNow });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
var environment = new Mock<IHostEnvironment>();
|
var environment = new Mock<IHostEnvironment>();
|
||||||
environment.SetupGet(x => x.ContentRootPath).Returns(root);
|
environment.SetupGet(x => x.ContentRootPath).Returns(root);
|
||||||
try
|
try
|
||||||
@@ -129,7 +138,18 @@ public sealed class BackgroundWorkerTenantTests
|
|||||||
var files = Directory.GetFiles(Path.Combine(root, "exports"), "*.json");
|
var files = Directory.GetFiles(Path.Combine(root, "exports"), "*.json");
|
||||||
Assert.Equal(2, files.Length);
|
Assert.Equal(2, files.Length);
|
||||||
Assert.DoesNotContain(files, path => path.Contains("user-1", StringComparison.Ordinal) || path.Contains("user-2", StringComparison.Ordinal));
|
Assert.DoesNotContain(files, path => path.Contains("user-1", StringComparison.Ordinal) || path.Contains("user-2", StringComparison.Ordinal));
|
||||||
var owners = files.Select(path => JsonDocument.Parse(System.IO.File.ReadAllText(path)).RootElement.GetProperty("OwnerUserId").GetString()).Order().ToArray();
|
var owners = new List<string?>();
|
||||||
|
foreach (var path in files)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(System.IO.File.ReadAllText(path));
|
||||||
|
var rootElement = document.RootElement;
|
||||||
|
var owner = rootElement.GetProperty("OwnerUserId").GetString();
|
||||||
|
owners.Add(owner);
|
||||||
|
var attempt = Assert.Single(rootElement.GetProperty("EmailSendAttempts").EnumerateArray());
|
||||||
|
Assert.Equal(owner == "user-1" ? "gmail" : "microsoft", attempt.GetProperty("Provider").GetString());
|
||||||
|
Assert.False(attempt.TryGetProperty("PayloadHash", out _));
|
||||||
|
}
|
||||||
|
owners.Sort(StringComparer.Ordinal);
|
||||||
Assert.Equal(new[] { "user-1", "user-2" }, owners);
|
Assert.Equal(new[] { "user-1", "user-2" }, owners);
|
||||||
Assert.Empty(Directory.GetFiles(Path.Combine(root, "exports"), "*.tmp"));
|
Assert.Empty(Directory.GetFiles(Path.Combine(root, "exports"), "*.tmp"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,22 @@ public sealed class BackupControllerTests
|
|||||||
{
|
{
|
||||||
await using var db = CreateDb();
|
await using var db = CreateDb();
|
||||||
db.Companies.Add(new Company { Name = "Acme", OwnerUserId = "user-1" });
|
db.Companies.Add(new Company { Name = "Acme", OwnerUserId = "user-1" });
|
||||||
db.JobApplications.Add(new JobApplication { JobTitle = "Backend Developer", OwnerUserId = "user-1" });
|
var job = new JobApplication { JobTitle = "Backend Developer", OwnerUserId = "user-1" };
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.EmailSendAttempts.Add(new EmailSendAttempt
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
Provider = "gmail",
|
||||||
|
ClientRequestId = Guid.NewGuid().ToString(),
|
||||||
|
PayloadHash = new string('a', 64),
|
||||||
|
Status = EmailSendStatuses.Sent,
|
||||||
|
ProviderMessageId = "synthetic-message-id",
|
||||||
|
CreatedAtUtc = DateTime.UtcNow,
|
||||||
|
CompletedAtUtc = DateTime.UtcNow,
|
||||||
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var provider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}")));
|
var provider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}")));
|
||||||
@@ -47,6 +62,14 @@ public sealed class BackupControllerTests
|
|||||||
Assert.Equal("application/octet-stream", file.ContentType);
|
Assert.Equal("application/octet-stream", file.ContentType);
|
||||||
Assert.EndsWith(".jtbackup", file.FileDownloadName);
|
Assert.EndsWith(".jtbackup", file.FileDownloadName);
|
||||||
Assert.NotEmpty(file.FileContents);
|
Assert.NotEmpty(file.FileContents);
|
||||||
|
|
||||||
|
var protectedText = System.Text.Encoding.UTF8.GetString(file.FileContents);
|
||||||
|
var json = Convert.FromBase64String(provider.CreateProtector("JobTrackerApi.Backup.v1").Unprotect(protectedText));
|
||||||
|
using var document = System.Text.Json.JsonDocument.Parse(json);
|
||||||
|
var attempt = Assert.Single(document.RootElement.GetProperty("Data").GetProperty("EmailSendAttempts").EnumerateArray());
|
||||||
|
Assert.Equal("gmail", attempt.GetProperty("Provider").GetString());
|
||||||
|
Assert.Equal("sent", attempt.GetProperty("Status").GetString());
|
||||||
|
Assert.False(attempt.TryGetProperty("PayloadHash", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JobTrackerContext CreateDb()
|
private static JobTrackerContext CreateDb()
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ public sealed class EmailSendAttemptStoreTests
|
|||||||
property.Name.Contains("Recipient", StringComparison.OrdinalIgnoreCase));
|
property.Name.Contains("Recipient", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Hard_job_deletion_cascades_only_that_jobs_attempts()
|
||||||
|
{
|
||||||
|
await using var fixture = await Fixture.CreateAsync();
|
||||||
|
var ownerOne = fixture.Store("user-1");
|
||||||
|
var ownerTwo = fixture.Store("user-2");
|
||||||
|
var mine = await ownerOne.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('3')), default);
|
||||||
|
var theirs = await ownerTwo.CreateAsync(new(fixture.OtherJobId, "microsoft", Guid.NewGuid().ToString(), Hash('4')), default);
|
||||||
|
|
||||||
|
await fixture.HardDeleteJobAsync("user-1", fixture.JobId);
|
||||||
|
|
||||||
|
Assert.Null(await ownerOne.GetAsync(mine.Attempt.Id, default));
|
||||||
|
Assert.NotNull(await ownerTwo.GetAsync(theirs.Attempt.Id, default));
|
||||||
|
Assert.Equal(1, await fixture.AttemptCountIgnoringFiltersAsync());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Restart_recovery_is_tenant_visible_idempotent_and_never_requeues_delivery()
|
public async Task Restart_recovery_is_tenant_visible_idempotent_and_never_requeues_delivery()
|
||||||
{
|
{
|
||||||
@@ -139,6 +155,20 @@ public sealed class EmailSendAttemptStoreTests
|
|||||||
return new UserNotificationStore(db, Time);
|
return new UserNotificationStore(db, Time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task HardDeleteJobAsync(string userId, int jobId)
|
||||||
|
{
|
||||||
|
await using var db = CreateDb(options, userId);
|
||||||
|
var job = await db.JobApplications.SingleAsync(item => item.Id == jobId);
|
||||||
|
db.JobApplications.Remove(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AttemptCountIgnoringFiltersAsync()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb(options, null);
|
||||||
|
return await db.EmailSendAttempts.IgnoreQueryFilters().CountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private static JobTrackerContext CreateDb(DbContextOptions<JobTrackerContext> options, string? userId)
|
private static JobTrackerContext CreateDb(DbContextOptions<JobTrackerContext> options, string? userId)
|
||||||
{
|
{
|
||||||
var currentUser = new Mock<ICurrentUserService>();
|
var currentUser = new Mock<ICurrentUserService>();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.DataProtection;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
namespace JobTrackerApi.Controllers
|
namespace JobTrackerApi.Controllers
|
||||||
{
|
{
|
||||||
@@ -73,6 +74,21 @@ namespace JobTrackerApi.Controllers
|
|||||||
.Where(e => jobIds.Contains(e.JobApplicationId))
|
.Where(e => jobIds.Contains(e.JobApplicationId))
|
||||||
.OrderBy(e => e.At)
|
.OrderBy(e => e.At)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
var emailSendAttempts = await _db.EmailSendAttempts.AsNoTracking()
|
||||||
|
.Where(attempt => jobIds.Contains(attempt.JobApplicationId))
|
||||||
|
.OrderBy(attempt => attempt.CreatedAtUtc)
|
||||||
|
.Select(attempt => new EmailSendAttemptExport(
|
||||||
|
attempt.Id,
|
||||||
|
attempt.JobApplicationId,
|
||||||
|
attempt.Provider,
|
||||||
|
attempt.ClientRequestId,
|
||||||
|
attempt.Status,
|
||||||
|
attempt.ProviderMessageId,
|
||||||
|
attempt.FailureCategory,
|
||||||
|
attempt.CreatedAtUtc,
|
||||||
|
attempt.StartedAtUtc,
|
||||||
|
attempt.CompletedAtUtc))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
var rules = await _db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
|
var rules = await _db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
return new
|
return new
|
||||||
@@ -82,6 +98,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
Correspondence = correspondence,
|
Correspondence = correspondence,
|
||||||
Attachments = attachments,
|
Attachments = attachments,
|
||||||
Events = events,
|
Events = events,
|
||||||
|
EmailSendAttempts = emailSendAttempts,
|
||||||
Rules = rules
|
Rules = rules
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,3 +25,15 @@ public sealed class EmailSendAttempt
|
|||||||
public DateTime? StartedAtUtc { get; set; }
|
public DateTime? StartedAtUtc { get; set; }
|
||||||
public DateTime? CompletedAtUtc { get; set; }
|
public DateTime? CompletedAtUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record EmailSendAttemptExport(
|
||||||
|
Guid Id,
|
||||||
|
int JobApplicationId,
|
||||||
|
string Provider,
|
||||||
|
string ClientRequestId,
|
||||||
|
string Status,
|
||||||
|
string? ProviderMessageId,
|
||||||
|
string? FailureCategory,
|
||||||
|
DateTime CreatedAtUtc,
|
||||||
|
DateTime? StartedAtUtc,
|
||||||
|
DateTime? CompletedAtUtc);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Security.Cryptography;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace JobTrackerApi.Services;
|
namespace JobTrackerApi.Services;
|
||||||
@@ -61,6 +62,21 @@ public sealed class DailyExportHostedService(
|
|||||||
Correspondence = await db.Correspondences.AsNoTracking().Where(message => jobIds.Contains(message.JobApplicationId)).OrderBy(message => message.Date).ToListAsync(cancellationToken),
|
Correspondence = await db.Correspondences.AsNoTracking().Where(message => jobIds.Contains(message.JobApplicationId)).OrderBy(message => message.Date).ToListAsync(cancellationToken),
|
||||||
Attachments = await db.Attachments.AsNoTracking().Where(attachment => jobIds.Contains(attachment.JobApplicationId)).OrderBy(attachment => attachment.UploadDate).ToListAsync(cancellationToken),
|
Attachments = await db.Attachments.AsNoTracking().Where(attachment => jobIds.Contains(attachment.JobApplicationId)).OrderBy(attachment => attachment.UploadDate).ToListAsync(cancellationToken),
|
||||||
Events = await db.JobEvents.AsNoTracking().Where(jobEvent => jobIds.Contains(jobEvent.JobApplicationId)).OrderBy(jobEvent => jobEvent.At).ToListAsync(cancellationToken),
|
Events = await db.JobEvents.AsNoTracking().Where(jobEvent => jobIds.Contains(jobEvent.JobApplicationId)).OrderBy(jobEvent => jobEvent.At).ToListAsync(cancellationToken),
|
||||||
|
EmailSendAttempts = await db.EmailSendAttempts.AsNoTracking()
|
||||||
|
.Where(attempt => jobIds.Contains(attempt.JobApplicationId))
|
||||||
|
.OrderBy(attempt => attempt.CreatedAtUtc)
|
||||||
|
.Select(attempt => new EmailSendAttemptExport(
|
||||||
|
attempt.Id,
|
||||||
|
attempt.JobApplicationId,
|
||||||
|
attempt.Provider,
|
||||||
|
attempt.ClientRequestId,
|
||||||
|
attempt.Status,
|
||||||
|
attempt.ProviderMessageId,
|
||||||
|
attempt.FailureCategory,
|
||||||
|
attempt.CreatedAtUtc,
|
||||||
|
attempt.StartedAtUtc,
|
||||||
|
attempt.CompletedAtUtc))
|
||||||
|
.ToListAsync(cancellationToken),
|
||||||
Rules = await RulesEngine.GetSettings(db, cancellationToken),
|
Rules = await RulesEngine.GetSettings(db, cancellationToken),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user