feat(email): add tenant-safe draft API
Expose bounded draft CRUD with owned-job validation and revision conflicts. Saving drafts never contacts providers or sends email.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class EmailDraftsControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Draft_routes_require_local_authentication()
|
||||
{
|
||||
var authorization = Assert.Single(typeof(EmailDraftsController)
|
||||
.GetCustomAttributes<AuthorizeAttribute>());
|
||||
Assert.Equal("local", authorization.AuthenticationSchemes);
|
||||
Assert.True(string.IsNullOrWhiteSpace(authorization.Policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_accepts_incomplete_owned_drafts_but_rejects_invalid_or_foreign_inputs()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var db = fixture.Context("user-1");
|
||||
var controller = Controller(db, "user-1");
|
||||
|
||||
var createdResult = await controller.Create(
|
||||
new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "GMAIL", "", "", "", null), default);
|
||||
var created = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<CreatedAtActionResult>(createdResult.Result).Value);
|
||||
Assert.Equal("gmail", created.Provider);
|
||||
Assert.Equal(1, created.Revision);
|
||||
Assert.Empty(created.To);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>((await controller.Create(
|
||||
new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "unknown", "", "", "", null), default)).Result);
|
||||
Assert.IsType<BadRequestObjectResult>((await controller.Create(
|
||||
new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "gmail", "not-an-address", "", "", null), default)).Result);
|
||||
Assert.IsType<NotFoundResult>((await controller.Create(
|
||||
new EmailDraftsController.CreateDraftRequest(fixture.UserTwoJobId, "gmail", "", "", "", null), default)).Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Direct_ids_and_job_lists_do_not_cross_tenants()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(seedDrafts: true);
|
||||
await using var db = fixture.Context("user-1");
|
||||
var controller = Controller(db, "user-1");
|
||||
|
||||
Assert.IsType<NotFoundResult>((await controller.Get(fixture.UserTwoDraftId, default)).Result);
|
||||
var foreignList = Assert.IsAssignableFrom<IReadOnlyList<EmailDraftsController.DraftDto>>(
|
||||
Assert.IsType<OkObjectResult>((await controller.List(fixture.UserTwoJobId, default)).Result).Value);
|
||||
Assert.Empty(foreignList);
|
||||
var ownList = Assert.IsAssignableFrom<IReadOnlyList<EmailDraftsController.DraftDto>>(
|
||||
Assert.IsType<OkObjectResult>((await controller.List(fixture.UserOneJobId, default)).Result).Value);
|
||||
Assert.Equal(fixture.UserOneDraftId, Assert.Single(ownList).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revision_conflicts_prevent_lost_updates_and_cross_tenant_deletes()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(seedDrafts: true);
|
||||
await using (var userOneDb = fixture.Context("user-1"))
|
||||
{
|
||||
var userOne = Controller(userOneDb, "user-1");
|
||||
var updatedResult = await userOne.Update(fixture.UserOneDraftId,
|
||||
new EmailDraftsController.UpdateDraftRequest(1, "new@example.test", "Updated", "Updated body"), default);
|
||||
var updated = Assert.IsType<EmailDraftsController.DraftDto>(Assert.IsType<OkObjectResult>(updatedResult.Result).Value);
|
||||
Assert.Equal(2, updated.Revision);
|
||||
Assert.Equal("Updated body", updated.BodyText);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>((await userOne.Update(fixture.UserOneDraftId,
|
||||
new EmailDraftsController.UpdateDraftRequest(1, "stale@example.test", "Stale", "Stale body"), default)).Result);
|
||||
Assert.IsType<ConflictObjectResult>(await userOne.Delete(fixture.UserOneDraftId, 1, default));
|
||||
}
|
||||
|
||||
await using (var userTwoDb = fixture.Context("user-2"))
|
||||
{
|
||||
var userTwo = Controller(userTwoDb, "user-2");
|
||||
Assert.IsType<NotFoundResult>((await userTwo.Update(fixture.UserOneDraftId,
|
||||
new EmailDraftsController.UpdateDraftRequest(2, "other@example.test", "Other", "Other body"), default)).Result);
|
||||
Assert.IsType<NotFoundResult>(await userTwo.Delete(fixture.UserOneDraftId, 2, default));
|
||||
}
|
||||
|
||||
await using (var userOneDb = fixture.Context("user-1"))
|
||||
Assert.IsType<NoContentResult>(await Controller(userOneDb, "user-1").Delete(fixture.UserOneDraftId, 2, default));
|
||||
|
||||
await using var verify = fixture.Context(null);
|
||||
var remaining = Assert.Single(await verify.EmailDrafts.IgnoreQueryFilters().AsNoTracking().ToListAsync());
|
||||
Assert.Equal("user-2", remaining.OwnerUserId);
|
||||
}
|
||||
|
||||
private static EmailDraftsController Controller(JobTrackerContext db, string userId)
|
||||
{
|
||||
var controller = new EmailDraftsController(
|
||||
db,
|
||||
new EmailProviderRegistry(new[] { new FakeProvider() }),
|
||||
TimeProvider.System);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")),
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private sealed class FakeProvider : IEmailProvider
|
||||
{
|
||||
public string ProviderKey => "gmail";
|
||||
public Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) => Task.FromResult<EmailConnectionInfo?>(null);
|
||||
public Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<EmailDeliveryResult> SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class Fixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JobTrackerContext> _options;
|
||||
|
||||
private Fixture(SqliteConnection connection, DbContextOptions<JobTrackerContext> options)
|
||||
{
|
||||
_connection = connection;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public int UserOneJobId { get; private set; }
|
||||
public int UserTwoJobId { get; private set; }
|
||||
public Guid UserOneDraftId { get; private set; }
|
||||
public Guid UserTwoDraftId { get; private set; }
|
||||
|
||||
public static async Task<Fixture> CreateAsync(bool seedDrafts = false)
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options;
|
||||
var fixture = new Fixture(connection, options);
|
||||
await using var db = fixture.Context(null);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var companyOne = new Company { Name = "One", OwnerUserId = "user-1" };
|
||||
var companyTwo = new Company { Name = "Two", OwnerUserId = "user-2" };
|
||||
db.Companies.AddRange(companyOne, companyTwo);
|
||||
await db.SaveChangesAsync();
|
||||
var jobOne = new JobApplication { JobTitle = "One", CompanyId = companyOne.Id, OwnerUserId = "user-1" };
|
||||
var jobTwo = new JobApplication { JobTitle = "Two", CompanyId = companyTwo.Id, OwnerUserId = "user-2" };
|
||||
db.JobApplications.AddRange(jobOne, jobTwo);
|
||||
await db.SaveChangesAsync();
|
||||
fixture.UserOneJobId = jobOne.Id;
|
||||
fixture.UserTwoJobId = jobTwo.Id;
|
||||
if (seedDrafts)
|
||||
{
|
||||
var one = Draft("user-1", jobOne.Id, "one@example.test");
|
||||
var two = Draft("user-2", jobTwo.Id, "two@example.test");
|
||||
db.EmailDrafts.AddRange(one, two);
|
||||
await db.SaveChangesAsync();
|
||||
fixture.UserOneDraftId = one.Id;
|
||||
fixture.UserTwoDraftId = two.Id;
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public JobTrackerContext Context(string? userId)
|
||||
{
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns(userId);
|
||||
return new JobTrackerContext(_options, currentUser.Object);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => _connection.DisposeAsync();
|
||||
|
||||
private static EmailDraft Draft(string ownerUserId, int jobApplicationId, string recipient) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = ownerUserId,
|
||||
JobApplicationId = jobApplicationId,
|
||||
Provider = "gmail",
|
||||
To = recipient,
|
||||
Subject = "Synthetic",
|
||||
BodyText = "Synthetic private draft.",
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
UpdatedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Security.Claims;
|
||||
using System.Net.Mail;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/email/drafts")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class EmailDraftsController(
|
||||
JobTrackerContext db,
|
||||
IEmailProviderRegistry providers,
|
||||
TimeProvider timeProvider) : ControllerBase
|
||||
{
|
||||
public sealed record DraftDto(
|
||||
Guid Id,
|
||||
int JobApplicationId,
|
||||
string Provider,
|
||||
string To,
|
||||
string Subject,
|
||||
string BodyText,
|
||||
string? ThreadId,
|
||||
long Revision,
|
||||
DateTime CreatedAtUtc,
|
||||
DateTime UpdatedAtUtc);
|
||||
|
||||
public sealed record CreateDraftRequest(
|
||||
int JobApplicationId,
|
||||
string? Provider,
|
||||
string? To,
|
||||
string? Subject,
|
||||
string? BodyText,
|
||||
string? ThreadId);
|
||||
|
||||
public sealed record UpdateDraftRequest(long Revision, string? To, string? Subject, string? BodyText);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<DraftDto>>> List(
|
||||
[FromQuery] int jobApplicationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
if (jobApplicationId <= 0) return BadRequest("A valid job application is required.");
|
||||
|
||||
return Ok(await db.EmailDrafts.AsNoTracking()
|
||||
.Where(draft => draft.OwnerUserId == ownerUserId && draft.JobApplicationId == jobApplicationId)
|
||||
.OrderByDescending(draft => draft.UpdatedAtUtc)
|
||||
.Select(draft => new DraftDto(
|
||||
draft.Id,
|
||||
draft.JobApplicationId,
|
||||
draft.Provider,
|
||||
draft.To,
|
||||
draft.Subject,
|
||||
draft.BodyText,
|
||||
draft.ThreadId,
|
||||
draft.Revision,
|
||||
draft.CreatedAtUtc,
|
||||
draft.UpdatedAtUtc))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<DraftDto>> Get(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
var draft = await db.EmailDrafts.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == id && item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
return draft is null ? NotFound() : Ok(ToDto(draft));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<DraftDto>> Create(CreateDraftRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
if (request.JobApplicationId <= 0) return BadRequest("A valid job application is required.");
|
||||
var provider = providers.Get(request.Provider);
|
||||
if (provider is null) return BadRequest("Unknown email provider.");
|
||||
if (!TryNormalizeContent(request.To, request.Subject, request.BodyText, out var recipient, out var subject, out var bodyText, out var error))
|
||||
return BadRequest(error);
|
||||
var threadId = string.IsNullOrWhiteSpace(request.ThreadId) ? null : request.ThreadId.Trim();
|
||||
if (threadId?.Length > 512) return BadRequest("Thread ID must be at most 512 characters.");
|
||||
|
||||
var ownsJob = await db.JobApplications.AsNoTracking()
|
||||
.AnyAsync(job => job.Id == request.JobApplicationId && job.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (!ownsJob) return NotFound();
|
||||
|
||||
var now = timeProvider.GetUtcNow().UtcDateTime;
|
||||
var draft = new EmailDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = ownerUserId,
|
||||
JobApplicationId = request.JobApplicationId,
|
||||
Provider = provider.ProviderKey.ToLowerInvariant(),
|
||||
To = recipient,
|
||||
Subject = subject,
|
||||
BodyText = bodyText,
|
||||
ThreadId = threadId,
|
||||
Revision = 1,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
};
|
||||
db.EmailDrafts.Add(draft);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return CreatedAtAction(nameof(Get), new { id = draft.Id }, ToDto(draft));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<DraftDto>> Update(Guid id, UpdateDraftRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
if (request.Revision <= 0) return BadRequest("A positive revision is required.");
|
||||
if (!TryNormalizeContent(request.To, request.Subject, request.BodyText, out var recipient, out var subject, out var bodyText, out var error))
|
||||
return BadRequest(error);
|
||||
|
||||
var now = timeProvider.GetUtcNow().UtcDateTime;
|
||||
var affected = await db.EmailDrafts
|
||||
.Where(draft => draft.Id == id && draft.OwnerUserId == ownerUserId && draft.Revision == request.Revision)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(draft => draft.To, recipient)
|
||||
.SetProperty(draft => draft.Subject, subject)
|
||||
.SetProperty(draft => draft.BodyText, bodyText)
|
||||
.SetProperty(draft => draft.Revision, draft => draft.Revision + 1)
|
||||
.SetProperty(draft => draft.UpdatedAtUtc, now), cancellationToken);
|
||||
if (affected == 0)
|
||||
{
|
||||
var exists = await db.EmailDrafts.AsNoTracking()
|
||||
.AnyAsync(draft => draft.Id == id && draft.OwnerUserId == ownerUserId, cancellationToken);
|
||||
return exists
|
||||
? Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before saving again." })
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
return await Get(id, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, [FromQuery] long revision, CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
if (revision <= 0) return BadRequest("A positive revision is required.");
|
||||
|
||||
var affected = await db.EmailDrafts
|
||||
.Where(draft => draft.Id == id && draft.OwnerUserId == ownerUserId && draft.Revision == revision)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
if (affected == 1) return NoContent();
|
||||
var exists = await db.EmailDrafts.AsNoTracking()
|
||||
.AnyAsync(draft => draft.Id == id && draft.OwnerUserId == ownerUserId, cancellationToken);
|
||||
return exists
|
||||
? Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before deleting it." })
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
private string? GetOwnerUserId() =>
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
|
||||
private static bool TryNormalizeContent(
|
||||
string? to,
|
||||
string? subject,
|
||||
string? bodyText,
|
||||
out string recipient,
|
||||
out string normalizedSubject,
|
||||
out string normalizedBody,
|
||||
out string error)
|
||||
{
|
||||
recipient = to?.Trim() ?? string.Empty;
|
||||
normalizedSubject = subject?.Trim() ?? string.Empty;
|
||||
normalizedBody = bodyText ?? string.Empty;
|
||||
error = string.Empty;
|
||||
if (recipient.Length > 320 || (recipient.Length > 0 && !MailAddress.TryCreate(recipient, out _)))
|
||||
error = "Recipient must be empty or a valid address of at most 320 characters.";
|
||||
else if (normalizedSubject.Length > 998)
|
||||
error = "Subject must be at most 998 characters.";
|
||||
else if (normalizedBody.Length > 200_000)
|
||||
error = "Body must be at most 200000 characters.";
|
||||
return error.Length == 0;
|
||||
}
|
||||
|
||||
private static DraftDto ToDto(EmailDraft draft) => new(
|
||||
draft.Id,
|
||||
draft.JobApplicationId,
|
||||
draft.Provider,
|
||||
draft.To,
|
||||
draft.Subject,
|
||||
draft.BodyText,
|
||||
draft.ThreadId,
|
||||
draft.Revision,
|
||||
draft.CreatedAtUtc,
|
||||
draft.UpdatedAtUtc);
|
||||
}
|
||||
Reference in New Issue
Block a user