Files
jobtrackingapp/JobTrackerApi.Tests/ImapProviderTests.cs
T
cesnimda a8e2f4dc4a
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
feat(email): add ImapProvider (generic IMAP for unsupported providers)
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>
2026-07-11 19:40:50 +02:00

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);
}
}