Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 0 additions & 335 deletions Darling/Darling.Tests/AlertDeliveryChannelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -938,339 +938,4 @@ private static int CountOccurrences(string haystack, string needle)

return count;
}

/// <summary>
/// A loopback endpoint that records the bodies posted to it. <see cref="System.Net.Sockets.TcpListener"/>
/// rather than <c>HttpListener</c> on purpose: HttpListener wants a URL ACL on Windows, and the four
/// lines of HTTP a webhook POST needs are cheaper than that dependency. The same choice
/// <c>NpgsqlRootCertificateValidationTests</c> and <c>DarlingStoreUpgradeTests</c> already make.
/// </summary>
private sealed class CapturingWebhookEndpoint : IDisposable
{
private readonly TcpListener _listener;
private readonly List<string> _bodies = new();
private readonly CancellationTokenSource _stop = new();
private readonly Task _accepting;

public CapturingWebhookEndpoint()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Url = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/hook";
_accepting = Task.Run(AcceptLoopAsync);
}

public string Url { get; }

/// <summary>
/// Safe to read without synchronization once the send has been awaited: each body is appended before
/// its response is written, and the sender awaits every response in turn.
/// </summary>
public IReadOnlyList<string> Bodies => _bodies;

private async Task AcceptLoopAsync()
{
try
{
while (!_stop.IsCancellationRequested)
{
using var client = await _listener.AcceptTcpClientAsync(_stop.Token);
using var stream = client.GetStream();
_bodies.Add(await ReadRequestBodyAsync(stream));

var response = Encoding.ASCII.GetBytes(
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
await stream.WriteAsync(response, _stop.Token);
await stream.FlushAsync(_stop.Token);
}
}
catch (OperationCanceledException) { /* Dispose */ }
catch (System.Net.Sockets.SocketException) { /* listener stopped */ }
catch (ObjectDisposedException) { /* listener stopped */ }
}

/// <summary>Reads headers to the blank line, then exactly Content-Length bytes of body.</summary>
private static async Task<string> ReadRequestBodyAsync(NetworkStream stream)
{
var buffer = new byte[16 * 1024];
var received = new List<byte>(capacity: 16 * 1024);
int headerEnd;

while ((headerEnd = IndexOfHeaderEnd(received)) < 0)
{
var read = await stream.ReadAsync(buffer);
if (read == 0)
{
return Encoding.UTF8.GetString(received.ToArray());
}

received.AddRange(new ArraySegment<byte>(buffer, 0, read));
}

var headers = Encoding.ASCII.GetString(received.ToArray(), 0, headerEnd);
var contentLength = ParseContentLength(headers);
var bodyStart = headerEnd + 4;

while (received.Count - bodyStart < contentLength)
{
var read = await stream.ReadAsync(buffer);
if (read == 0)
{
break;
}

received.AddRange(new ArraySegment<byte>(buffer, 0, read));
}

var body = received.ToArray();
var available = Math.Min(contentLength, body.Length - bodyStart);
return Encoding.UTF8.GetString(body, bodyStart, Math.Max(0, available));
}

private static int IndexOfHeaderEnd(List<byte> bytes)
{
for (int i = 0; i + 3 < bytes.Count; i++)
{
if (bytes[i] == (byte)'\r' && bytes[i + 1] == (byte)'\n' &&
bytes[i + 2] == (byte)'\r' && bytes[i + 3] == (byte)'\n')
{
return i;
}
}

return -1;
}

private static int ParseContentLength(string headers)
{
foreach (var line in headers.Split("\r\n", StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) &&
int.TryParse(line.AsSpan("Content-Length:".Length).Trim(), out var length))
{
return length;
}
}

return 0;
}

public void Dispose()
{
_stop.Cancel();
_listener.Stop();
try
{
_accepting.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException) { /* the cancellation above */ }

_stop.Dispose();
}
}

/// <summary>
/// A loopback SMTP sink that records the DATA of each message posted to it, quoted-printable soft line
/// breaks removed so an assertion on a phrase cannot fail because MIME wrapped it at column 76.
/// <para>Exists because email is the only channel with no interception point short of the protocol:
/// there is no builder-returns-the-payload seam between <c>EmailSendCore</c> and the wire. Without it a
/// mutation that drops the detail on the way to the email template alone passes every other pin — which
/// is exactly the defect #3296 reported, so leaving that one hop unpinned was not an option.</para>
/// </summary>
private sealed class CapturingSmtpEndpoint : IDisposable
{
private readonly TcpListener _listener;
private readonly List<string> _messages = new();
private readonly CancellationTokenSource _stop = new();
private readonly Task _accepting;

public CapturingSmtpEndpoint()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_accepting = Task.Run(AcceptLoopAsync);
}

public int Port { get; }

/// <summary>Safe to read once the send has been awaited; see the webhook endpoint's note.</summary>
public IReadOnlyList<string> Messages => _messages;

private async Task AcceptLoopAsync()
{
try
{
while (!_stop.IsCancellationRequested)
{
using var client = await _listener.AcceptTcpClientAsync(_stop.Token);
using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, false, 8192, leaveOpen: true);
using var writer = new StreamWriter(stream, new UTF8Encoding(false), 8192, leaveOpen: true) { AutoFlush = true, NewLine = "\r\n" };

await writer.WriteLineAsync("220 localhost ESMTP capture");

var data = new StringBuilder();
var inData = false;
string? line;
while ((line = await reader.ReadLineAsync(_stop.Token)) is not null)
{
if (inData)
{
if (line == ".")
{
_messages.Add(DecodeMimeText(data.ToString()));
data.Clear();
inData = false;
await writer.WriteLineAsync("250 OK queued");
continue;
}

/* Transparency: a body line starting with '.' arrives doubled. CRLF explicitly,
not AppendLine: MIME line endings are CRLF by the spec the parsing below
depends on, and Environment.NewLine is LF on the platform this is written on. */
data.Append(line.StartsWith("..", StringComparison.Ordinal) ? line.Substring(1) : line).Append("\r\n");
continue;
}

if (line.StartsWith("EHLO", StringComparison.OrdinalIgnoreCase) ||
line.StartsWith("HELO", StringComparison.OrdinalIgnoreCase))
{
/* No extensions advertised, so the client never tries STARTTLS or AUTH. */
await writer.WriteLineAsync("250 localhost");
}
else if (line.StartsWith("DATA", StringComparison.OrdinalIgnoreCase))
{
inData = true;
await writer.WriteLineAsync("354 End data with <CRLF>.<CRLF>");
}
else if (line.StartsWith("QUIT", StringComparison.OrdinalIgnoreCase))
{
await writer.WriteLineAsync("221 Bye");
break;
}
else
{
await writer.WriteLineAsync("250 OK");
}
}
}
}
catch (OperationCanceledException) { /* Dispose */ }
catch (IOException) { /* client went away */ }
catch (SocketException) { /* listener stopped */ }
catch (ObjectDisposedException) { /* listener stopped */ }
}

/// <summary>
/// Returns the DECODED text of every MIME part, concatenated. Both transfer encodings this message
/// actually uses are handled — .NET picks base64 for the utf-8 plain-text alternate view and
/// quoted-printable for the us-ascii HTML one — because an assertion against the raw DATA would be
/// asserting against an encoding choice rather than against the delivered words.
/// </summary>
private static string DecodeMimeText(string raw)
{
var boundary = System.Text.RegularExpressions.Regex.Match(raw, @"boundary=(\S+)");
if (!boundary.Success)
{
return DecodeQuotedPrintable(raw);
}

var parts = raw.Split("--" + boundary.Groups[1].Value, StringSplitOptions.None);
var decoded = new StringBuilder();

foreach (var part in parts.Skip(1))
{
var split = part.IndexOf("\r\n\r\n", StringComparison.Ordinal);
if (split < 0)
{
continue;
}

var headers = part.Substring(0, split);
var body = part.Substring(split + 4);

decoded.Append(headers.Contains("base64", StringComparison.OrdinalIgnoreCase)
? Encoding.UTF8.GetString(Convert.FromBase64String(
new string(body.Where(c => !char.IsWhiteSpace(c)).ToArray())))
: DecodeQuotedPrintable(body)).Append("\r\n");
}

return decoded.ToString();
}

/// <summary>Drops soft line breaks ("=" at end of line) and decodes "=XX" octets.</summary>
private static string DecodeQuotedPrintable(string raw)
{
var unfolded = raw.Replace("=\r\n", "", StringComparison.Ordinal).Replace("=\n", "", StringComparison.Ordinal);
var sb = new StringBuilder(unfolded.Length);

for (int i = 0; i < unfolded.Length; i++)
{
if (unfolded[i] == '=' && i + 2 < unfolded.Length &&
Uri.IsHexDigit(unfolded[i + 1]) && Uri.IsHexDigit(unfolded[i + 2]))
{
sb.Append((char)Convert.ToInt32(unfolded.Substring(i + 1, 2), 16));
i += 2;
continue;
}

sb.Append(unfolded[i]);
}

return sb.ToString();
}

public void Dispose()
{
_stop.Cancel();
_listener.Stop();
try
{
_accepting.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException) { /* the cancellation above */ }

_stop.Dispose();
}
}

/// <summary>
/// <see cref="DiscardingHistoryStore"/> with the records kept, so a test can read what the row would
/// have held. Safe to read after the send has been awaited: <c>SendFindingAlertAsync</c> awaits its
/// own <c>RecordAlertAsync</c>.
/// </summary>
private sealed class CapturingHistoryStore : IAlertHistoryStore
{
public List<AlertHistoryRecord> Records { get; } = new();

public Task RecordAlertAsync(AlertHistoryRecord record)
{
Records.Add(record);
return Task.CompletedTask;
}

public Task<DateTime?> GetLastEmailSentUtcAsync(string serverId, string metricName, string? dedupKey = null) =>
Task.FromResult<DateTime?>(null);

public Task<DateTime?> GetLastWebhookSentUtcAsync(string serverId, string metricName, string? dedupKey = null) =>
Task.FromResult<DateTime?>(null);

public Task<DateTime?> GetLastAlertTimeAsync(string serverId, string metricName, string? dedupKey = null) =>
Task.FromResult<DateTime?>(null);
}

private sealed class DiscardingHistoryStore : IAlertHistoryStore
{
public Task RecordAlertAsync(AlertHistoryRecord record) => Task.CompletedTask;

public Task<DateTime?> GetLastEmailSentUtcAsync(string serverId, string metricName, string? dedupKey = null) =>
Task.FromResult<DateTime?>(null);

public Task<DateTime?> GetLastWebhookSentUtcAsync(string serverId, string metricName, string? dedupKey = null) =>
Task.FromResult<DateTime?>(null);

public Task<DateTime?> GetLastAlertTimeAsync(string serverId, string metricName, string? dedupKey = null) =>
Task.FromResult<DateTime?>(null);
}
}
Loading
Loading