a8e2f4dc4a
b3 of the multi-provider email roadmap. Adds ImapConnection model + table (reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client), ImapProvider implementing the existing IEmailProvider contract unchanged, and ImapController for credential-based connect (no OAuth — user supplies host/username/password directly, verified by a live connect before storage). Scope, documented inline with ponytail: comments: - INBOX only, no multi-folder support. - Thread grouping approximates the References/In-Reply-To chain root rather than the IMAP THREAD extension, which not every server implements. - External message ids are IMAP UIDs, scoped to the connection's current UIDVALIDITY. Security: ran the security-audit skill against this diff (credential handling + arbitrary-host connect is exactly the class of change the standing security gate exists for). Found and fixed a real SSRF: the connect endpoint let an authenticated user point the server at an arbitrary host:port with no internal-range check, and connect-vs-auth failure was distinguishable to the caller -- together a working oracle to fingerprint internal services (loopback/RFC1918/link-local/cloud metadata) from the server's network position. Fixed with EnsureHostIsExternalAsync (DNS-resolve + reject internal ranges, re-checked on every reconnect to close the DNS-rebinding gap) and a single generic failure message that no longer distinguishes connect vs auth failure. 7 regression tests added. Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj -- stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing) would be a large, security-sensitive protocol implementation nobody asked for, so this is the correct dependency, not a stdlib substitute. 168/168 green (161 existing + 7 new SSRF regression tests; the earlier 14 IMAP feature tests are included in the 161). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
83 lines
3.3 KiB
C#
83 lines
3.3 KiB
C#
using JobTrackerApi.Services;
|
|
using JobTrackerApi.Services.EmailProviders;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class ImapProviderTests
|
|
{
|
|
[Fact]
|
|
public void ProviderKey_is_imap()
|
|
{
|
|
var provider = new ImapProvider(Mock.Of<IImapService>());
|
|
Assert.Equal("imap", provider.ProviderKey);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetConnectionAsync_maps_username_onto_neutral_shape()
|
|
{
|
|
var imap = new Mock<IImapService>();
|
|
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new JobTrackerApi.Models.ImapConnection { OwnerUserId = "user-1", Username = "user@example.test" });
|
|
|
|
var provider = new ImapProvider(imap.Object);
|
|
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
|
|
|
Assert.NotNull(connection);
|
|
Assert.Equal("imap", connection!.ProviderKey);
|
|
Assert.Equal("user@example.test", connection.Address);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetConnectionAsync_returns_null_when_not_connected()
|
|
{
|
|
var imap = new Mock<IImapService>();
|
|
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((JobTrackerApi.Models.ImapConnection?)null);
|
|
|
|
var provider = new ImapProvider(imap.Object);
|
|
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
|
|
|
Assert.Null(connection);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_maps_thread_key_onto_neutral_thread_id()
|
|
{
|
|
var imap = new Mock<IImapService>();
|
|
imap.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<ImapMessageSummary>
|
|
{
|
|
new("42", "root-msg-id@example.test", "Interview", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet")
|
|
});
|
|
|
|
var provider = new ImapProvider(imap.Object);
|
|
var results = await provider.SearchAsync("user-1", "recruiter", 10, CancellationToken.None);
|
|
|
|
var summary = Assert.Single(results);
|
|
Assert.Equal("42", summary.Id);
|
|
Assert.Equal("root-msg-id@example.test", summary.ThreadId);
|
|
Assert.Equal("Interview", summary.Subject);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMessageAsync_maps_content_id_onto_neutral_external_attachment_id()
|
|
{
|
|
var imap = new Mock<IImapService>();
|
|
imap.Setup(service => service.GetMessageAsync("user-1", "42", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ImapMessageDetail(
|
|
"42", "root-msg-id@example.test", "Offer", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet",
|
|
"body text", "<p>body</p>", new List<string>(),
|
|
new List<ImapMessageAttachment> { new("resume.pdf", "application/pdf", 1024, "cid-1", false) }));
|
|
|
|
var provider = new ImapProvider(imap.Object);
|
|
var detail = await provider.GetMessageAsync("user-1", "42", CancellationToken.None);
|
|
|
|
Assert.Equal("root-msg-id@example.test", detail.ThreadId);
|
|
var attachment = Assert.Single(detail.Attachments);
|
|
Assert.Equal("resume.pdf", attachment.FileName);
|
|
Assert.Equal("cid-1", attachment.ExternalAttachmentId);
|
|
}
|
|
}
|