From 38c6d5290920935441df90c6f0504cd53cb20d95 Mon Sep 17 00:00:00 2001 From: Ben Hutchison Date: Sun, 30 Jun 2024 11:41:08 -0700 Subject: [PATCH] Clean up and reorganize some of the fields sent in the alert. When triggering an alert, use the same dedupkey that from other checks with the same PagerDuty integration key, even if it's a different check. Added automated tests to verify JSON parsing of poorly-documented webhook payload from Freshping --- FreshPager.sln | 8 +- FreshPager.sln.DotSettings | 2 + FreshPager/{ => Data}/Configuration.cs | 3 +- .../Marshal/StringToOptionalIntConverter.cs | 14 ++ .../Data/Marshal/StringToTimespanConverter.cs | 27 +++ FreshPager/Data/WebhookPayload.cs | 205 ++++++++++++++++++ FreshPager/FreshPager.csproj | 12 +- FreshPager/Program.cs | 73 ++++--- FreshPager/WebhookPayload.cs | 73 ------- FreshPager/appsettings.json | 1 + FreshPager/packages.lock.json | 28 +-- Tests/.editorconfig | 41 ++++ Tests/GlobalUsings.cs | 3 + Tests/Tests.csproj | 30 +++ Tests/WebhookPayloadTest.cs | 109 ++++++++++ 15 files changed, 502 insertions(+), 127 deletions(-) create mode 100644 FreshPager.sln.DotSettings rename FreshPager/{ => Data}/Configuration.cs (61%) create mode 100644 FreshPager/Data/Marshal/StringToOptionalIntConverter.cs create mode 100644 FreshPager/Data/Marshal/StringToTimespanConverter.cs create mode 100644 FreshPager/Data/WebhookPayload.cs delete mode 100644 FreshPager/WebhookPayload.cs create mode 100644 Tests/.editorconfig create mode 100644 Tests/GlobalUsings.cs create mode 100644 Tests/Tests.csproj create mode 100644 Tests/WebhookPayloadTest.cs diff --git a/FreshPager.sln b/FreshPager.sln index 564c77a..2363dbc 100644 --- a/FreshPager.sln +++ b/FreshPager.sln @@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.10.35013.160 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FreshPager", "FreshPager\FreshPager.csproj", "{FFDBD983-BBD5-43D3-9B47-31EBF50C2D4F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FreshPager", "FreshPager\FreshPager.csproj", "{FFDBD983-BBD5-43D3-9B47-31EBF50C2D4F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{53127C8F-7BED-44A4-8A6C-73327AAE4DB6}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +17,10 @@ Global {FFDBD983-BBD5-43D3-9B47-31EBF50C2D4F}.Debug|Any CPU.Build.0 = Debug|Any CPU {FFDBD983-BBD5-43D3-9B47-31EBF50C2D4F}.Release|Any CPU.ActiveCfg = Release|Any CPU {FFDBD983-BBD5-43D3-9B47-31EBF50C2D4F}.Release|Any CPU.Build.0 = Release|Any CPU + {53127C8F-7BED-44A4-8A6C-73327AAE4DB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53127C8F-7BED-44A4-8A6C-73327AAE4DB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53127C8F-7BED-44A4-8A6C-73327AAE4DB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53127C8F-7BED-44A4-8A6C-73327AAE4DB6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/FreshPager.sln.DotSettings b/FreshPager.sln.DotSettings new file mode 100644 index 0000000..5d131b3 --- /dev/null +++ b/FreshPager.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/FreshPager/Configuration.cs b/FreshPager/Data/Configuration.cs similarity index 61% rename from FreshPager/Configuration.cs rename to FreshPager/Data/Configuration.cs index 958f5bd..cb9bbdf 100644 --- a/FreshPager/Configuration.cs +++ b/FreshPager/Data/Configuration.cs @@ -1,7 +1,8 @@ -namespace FreshPager; +namespace FreshPager.Data; public class Configuration { public IDictionary pagerDutyIntegrationKeysByService { get; } = new Dictionary(); + public ushort httpServerPort { get; set; } = 37374; } \ No newline at end of file diff --git a/FreshPager/Data/Marshal/StringToOptionalIntConverter.cs b/FreshPager/Data/Marshal/StringToOptionalIntConverter.cs new file mode 100644 index 0000000..fa73126 --- /dev/null +++ b/FreshPager/Data/Marshal/StringToOptionalIntConverter.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FreshPager.Data.Marshal; + +public class StringToOptionalIntConverter: JsonConverter { + + public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + return int.TryParse(reader.GetString(), out int number) ? number : null; + } + + public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options) => throw new NotImplementedException(); + +} \ No newline at end of file diff --git a/FreshPager/Data/Marshal/StringToTimespanConverter.cs b/FreshPager/Data/Marshal/StringToTimespanConverter.cs new file mode 100644 index 0000000..477e1de --- /dev/null +++ b/FreshPager/Data/Marshal/StringToTimespanConverter.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FreshPager.Data.Marshal; + +public abstract class StringToTimespanConverter: JsonConverter { + + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.GetString() is { } rawString ? intToTimeSpan(double.Parse(rawString)) : default; + + protected abstract TimeSpan intToTimeSpan(double number); + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) => throw new NotImplementedException(); + + public class FromSeconds: StringToTimespanConverter { + + protected override TimeSpan intToTimeSpan(double number) => TimeSpan.FromSeconds(number); + + } + + public class FromMilliseconds: StringToTimespanConverter { + + protected override TimeSpan intToTimeSpan(double number) => TimeSpan.FromMilliseconds(number); + + } + +} \ No newline at end of file diff --git a/FreshPager/Data/WebhookPayload.cs b/FreshPager/Data/WebhookPayload.cs new file mode 100644 index 0000000..7e1017f --- /dev/null +++ b/FreshPager/Data/WebhookPayload.cs @@ -0,0 +1,205 @@ +using FreshPager.Data.Marshal; +using System.Text.Json.Serialization; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + +namespace FreshPager.Data; + +public class WebhookPayload { + + /// + /// The title/subject/summary of the event + /// Examples: + /// Aldaviva HTTP (https://aldaviva.com) is DOWN. + /// Aldaviva SMTP (tcp://aldaviva.com:25) is UP. + /// + [JsonPropertyName("text")] + public string eventTitle { get; set; } + + /// + /// Numeric ID of the check + /// Examples: + /// 36897 + /// 829684 + /// + [JsonPropertyName("check_id")] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public int checkId { get; set; } + + /// + /// The friendly check name + /// Examples: + /// Aldaviva HTTP + /// Aldaviva SMTP + /// + [JsonPropertyName("check_name")] + public string checkName { get; set; } + + /// + /// The URL that was hit to do the health check + /// Examples: + /// https://aldaviva.com + /// tcp://aldaviva.com:25 + /// + [JsonPropertyName("check_url")] + public Uri checkedUrl { get; set; } + + /// + /// How long the health check had been willing to wait for a response (not how long it actually waited) + /// Example: + /// 0:30:00 + /// + [JsonPropertyName("request_timeout")] + [JsonConverter(typeof(StringToTimespanConverter.FromSeconds))] + public TimeSpan requestTimeout { get; set; } + + /// + /// Examples: + /// US East (N. Virginia) + /// Asia Pacific (Tokyo) + /// EU (Ireland) + /// Asia Pacific (Singapore) + /// Canada (Central) + /// Asia Pacific (Sydney) + /// US West (Oregon) + /// Asia Pacific (Mumbai) + /// South America (Sao Paulo) + /// EU (London) + /// + [JsonPropertyName("request_location")] + public string requestLocation { get; set; } + + /// + /// Example: + /// 2024-06-28T18:07:46.709971+00:00 + /// + [JsonPropertyName("request_datetime")] + public DateTimeOffset requestDateTime { get; set; } + + /// + /// Examples: + /// None (check is down due to a socket/processing exception like a timeout) + /// 200 (HTTP check is up) + /// 1 (TCP check is up) + /// + [JsonPropertyName("response_status_code")] + [JsonConverter(typeof(StringToOptionalIntConverter))] + public int? responseStatusCode { get; set; } + + /// + /// Examples: + /// Connection Timeout + /// Not Responding + /// Available + /// + [JsonPropertyName("response_summary")] + public string responseSummary { get; set; } + + public bool isServiceUp => responseSummary == "Available"; + + /// + /// Examples: + /// Not Responding + /// Available + /// + [JsonPropertyName("response_state")] + public string responseState { get; set; } + + /// + /// How long it actually took for the health check to get a response. + /// For the maximum time the check was willing to wait, see . + /// Examples: + /// 30003 + /// 17 + /// + [JsonPropertyName("response_time")] + [JsonConverter(typeof(StringToTimespanConverter.FromMilliseconds))] + public TimeSpan responseTime { get; set; } + + [JsonPropertyName("event_data")] + [JsonInclude] + private EventData eventData { get; set; } + + public string organizationSubdomain => eventData.organizationSubdomain; + public DateTimeOffset eventCreationDateTime => eventData.eventCreationDateTime; + public int eventId => eventData.eventId; + public int organizationId => eventData.organizationId; + public int webhookId => eventData.webhookId; + public EventFilter eventFilter => eventData.eventFilter; + + public override string ToString() => + $"{nameof(eventTitle)}: {eventTitle}, {nameof(checkId)}: {checkId}, {nameof(checkName)}: {checkName}, {nameof(checkedUrl)}: {checkedUrl}, {nameof(requestTimeout)}: {requestTimeout}, {nameof(requestLocation)}: {requestLocation}, {nameof(requestDateTime)}: {requestDateTime}, {nameof(responseStatusCode)}: {responseStatusCode}, {nameof(responseSummary)}: {responseSummary}, {nameof(isServiceUp)}: {isServiceUp}, {nameof(responseState)}: {responseState}, {nameof(responseTime)}: {responseTime}, {nameof(organizationSubdomain)}: {organizationSubdomain}, {nameof(eventCreationDateTime)}: {eventCreationDateTime}, {nameof(eventId)}: {eventId}, {nameof(organizationId)}: {organizationId}, {nameof(webhookId)}: {webhookId}, {nameof(eventFilter)}: {eventFilter}"; + + public class EventData { + + /// + /// The subdomain of the Freshping organization, also known as Freshping URL (not the Account Name) + /// Examples: + /// aldaviva + /// + [JsonPropertyName("org_name")] + public string organizationSubdomain { get; set; } + + /// + /// Example: + /// 2024-06-27T23:49:30.033405+00:00 + /// + [JsonPropertyName("event_created_on")] + public DateTimeOffset eventCreationDateTime { get; set; } + + /// + /// Unique ID for each webhook message sent + /// Examples: + /// 17960894 + /// 17960760 + /// + [JsonPropertyName("event_id")] + public int eventId { get; set; } + + /// + /// Example: + /// 10593 + /// + [JsonPropertyName("org_id")] + public int organizationId { get; set; } + + /// + /// Examples: + /// AL (all events) + /// AT (up and down events) + /// PE (performance degraded events) + /// PS (paused and restarted events) + /// + [JsonPropertyName("webhook_type")] + [JsonInclude] + private string eventFilterRaw { get; set; } + + /// + public EventFilter eventFilter => eventFilterRaw switch { + "AL" => EventFilter.ALL, + "AT" => EventFilter.UP_DOWN, + "PE" => EventFilter.DEGRADED_PERFORMANCE, + "PS" => EventFilter.PAUSED_UNPAUSED, + _ => throw new ArgumentOutOfRangeException(nameof(eventFilterRaw), eventFilterRaw, $"Unrecognized webhook_type value '{eventFilterRaw}'") + }; + + /// + /// The unique ID of the webhook integration + /// Example: + /// 35191 + /// + [JsonPropertyName("webhook_id")] + public int webhookId { get; set; } + + } + + public enum EventFilter { + + ALL, + UP_DOWN, + DEGRADED_PERFORMANCE, + PAUSED_UNPAUSED + + } + +} \ No newline at end of file diff --git a/FreshPager/FreshPager.csproj b/FreshPager/FreshPager.csproj index 46cf9f9..b0e10cd 100644 --- a/FreshPager/FreshPager.csproj +++ b/FreshPager/FreshPager.csproj @@ -6,6 +6,7 @@ win-x64;linux-arm;linux-arm64;linux-x64 enable enable + latest latestMajor true true @@ -13,7 +14,7 @@ Ben Hutchison © 2024 $(Authors) $(Authors) - 1.0.0 + 1.0.1 freshping.ico app.manifest @@ -28,10 +29,17 @@ + - + + + + ..\..\PagerDuty\PagerDuty\bin\Release\netstandard2.0\PagerDuty.dll + + + \ No newline at end of file diff --git a/FreshPager/Program.cs b/FreshPager/Program.cs index 529f380..57ae1be 100644 --- a/FreshPager/Program.cs +++ b/FreshPager/Program.cs @@ -1,5 +1,6 @@ using Bom.Squad; -using FreshPager; +using FreshPager.Data; +using jaytwo.FluentUri; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Pager.Duty; @@ -17,15 +18,16 @@ .UseWindowsService() .UseSystemd(); -builder.WebHost.UseKestrel(options => options.ListenAnyIP(37374)); +builder.WebHost.UseKestrel((context, options) => options.ListenAnyIP(context.Configuration.GetValue(nameof(Configuration.httpServerPort), new Configuration().httpServerPort))); builder.Services .Configure(builder.Configuration) .AddHttpClient(); -WebApplication webapp = builder.Build(); +await using WebApplication webapp = builder.Build(); -var dedupKeys = new ConcurrentDictionary(); +int serviceCount = webapp.Services.GetRequiredService>().Value.pagerDutyIntegrationKeysByService.Keys.Count; +var dedupKeys = new ConcurrentDictionary(Math.Min(serviceCount * 2, Environment.ProcessorCount), serviceCount); webapp.MapPost("/", async Task ([FromBody] WebhookPayload payload, IOptions configuration, HttpClient http, ILogger logger) => { string serviceName = payload.checkName; @@ -34,59 +36,58 @@ if (configuration.Value.pagerDutyIntegrationKeysByService.TryGetValue(serviceName, out string? pagerDutyIntegrationKey)) { using IPagerDuty pagerDuty = new PagerDuty(pagerDutyIntegrationKey) { HttpClient = http }; - if (payload.responseSummary == "Available") { + if (payload.isServiceUp) { logger.LogDebug("{service} is available", serviceName); - if (dedupKeys.TryRemove(serviceName, out string? dedupKey)) { + if (dedupKeys.TryRemove(pagerDutyIntegrationKey, out string? dedupKey)) { await pagerDuty.Send(new ResolveAlert(dedupKey)); - logger.LogInformation("Resolved PagerDuty alert with dedupKey {key}", dedupKey); + logger.LogInformation("Resolved PagerDuty alert for {service} being up, using deduplication key {key}", serviceName, dedupKey); } else { logger.LogWarning("No known PagerDuty alerts for service {service}, not resolving anything", serviceName); } } else { - dedupKeys.TryGetValue(serviceName, out string? oldDedupKey); - string reportUrl = $"https://{payload.eventData.orgName}.freshping.io/reports?check_id={payload.checkId}"; - const uint MAX_ATTEMPTS = 20; + const uint MAX_TRIGGER_ATTEMPTS = 20; + logger.LogDebug("{service} is down", serviceName); + dedupKeys.TryGetValue(pagerDutyIntegrationKey, out string? oldDedupKey); + Uri reportUrl = new UriBuilder("https", $"{payload.organizationSubdomain}.freshping.io", -1, "reports").Uri.WithQueryParameter("check_id", payload.checkId); try { - string newDedupKey = await Retrier.Attempt(async i => { - AlertResponse alertResponse = await pagerDuty.Send(new TriggerAlert(Severity.Error, payload.text) { - Class = payload.responseSummary, - Client = "Freshping", - ClientUrl = reportUrl, - Component = payload.checkUrl, + string newDedupKey = await Retrier.Attempt(async _ => { + AlertResponse alertResponse = await pagerDuty.Send(new TriggerAlert(Severity.Error, payload.eventTitle) { DedupKey = oldDedupKey, + Timestamp = payload.requestDateTime, Links = { - new Link(reportUrl, $"{payload.checkName} report on Freshping"), - new Link(payload.checkUrl, "Checked service URL") + new Link(reportUrl, "Freshping Report"), + new Link(payload.checkedUrl, "Checked Service URL") }, - Images = { new Image("https://d3h0owdjgzys62.cloudfront.net/images/7876/live_cover_art/thumb2x/freshping_400.png", null, "Freshping") }, - Source = payload.requestLocation, - Timestamp = payload.requestDatetime, CustomDetails = new Dictionary { - { "statusCode", payload.responseStatusCode }, - { "state", payload.responseState }, - { "responseDuration", payload.responseTime } - } - }); + { "Check", payload.checkName }, + { "Request Location", payload.requestLocation }, + { "Response Duration (sec)", payload.responseTime.TotalSeconds }, + { "Response Status Code", payload.responseStatusCode?.ToString() ?? "(none)" }, + { "Service State", payload.responseState }, + { "Service Summary", payload.responseSummary } + }, - if (alertResponse.Status != "success") { - throw new ApplicationException($"{alertResponse.Status}: {alertResponse.Message}"); - } + // The following fields do not appear in the Android app UI + Class = payload.responseSummary, + Component = payload.checkedUrl.ToString(), + Source = payload.requestLocation + }); - dedupKeys[serviceName] = alertResponse.DedupKey; + dedupKeys[pagerDutyIntegrationKey] = alertResponse.DedupKey; return alertResponse.DedupKey; - }, MAX_ATTEMPTS, n => TimeSpan.FromMinutes(n * n), exception => exception is not (OutOfMemoryException or PagerDutyException { RetryAllowedAfterDelay: false })); + }, MAX_TRIGGER_ATTEMPTS, n => TimeSpan.FromMinutes(n * n), exception => exception is not (OutOfMemoryException or PagerDutyException { RetryAllowedAfterDelay: false })); - logger.LogInformation("Triggered alert in PagerDuty for {service}, returned dedupKey was {key}", serviceName, newDedupKey); + logger.LogInformation("Triggered alert in PagerDuty for {service} being down, got deduplication key {key}", serviceName, newDedupKey); } catch (Exception e) when (e is not OutOfMemoryException) { - logger.LogError(e, "Failed to trigger alert in PagerDuty after {attempts}, giving up", MAX_ATTEMPTS); - return Results.Problem(statusCode: StatusCodes.Status503ServiceUnavailable); + logger.LogError(e, "Failed to trigger alert in PagerDuty after {attempts}, giving up", MAX_TRIGGER_ATTEMPTS); + return Results.Problem(statusCode: StatusCodes.Status503ServiceUnavailable, detail: "Failed to trigger PagerDuty alert"); } } - return Results.NoContent(); + return Results.Created(); } else { - logger.LogWarning("No PagerDuty integration key found for Freshping service {service}", serviceName); + logger.LogWarning("No PagerDuty integration key configured for Freshping service {service}, not sending an alert to PagerDuty", serviceName); return Results.NotFound(serviceName); } }); diff --git a/FreshPager/WebhookPayload.cs b/FreshPager/WebhookPayload.cs deleted file mode 100644 index 59a4dde..0000000 --- a/FreshPager/WebhookPayload.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text.Json.Serialization; - -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - -namespace FreshPager; - -public class WebhookPayload { - - [JsonPropertyName("text")] - public string text { get; set; } - - [JsonPropertyName("check_id")] - public string checkId { get; set; } - - [JsonPropertyName("check_name")] - public string checkName { get; set; } - - [JsonPropertyName("check_url")] - public string checkUrl { get; set; } - - [JsonPropertyName("request_timeout")] - public string requestTimeout { get; set; } - - [JsonPropertyName("request_location")] - public string requestLocation { get; set; } - - [JsonPropertyName("request_datetime")] - public DateTimeOffset requestDatetime { get; set; } - - [JsonPropertyName("response_status_code")] - public string responseStatusCode { get; set; } - - [JsonPropertyName("response_summary")] - public string responseSummary { get; set; } - - [JsonPropertyName("response_state")] - public string responseState { get; set; } - - [JsonPropertyName("response_time")] - public string responseTime { get; set; } - - [JsonPropertyName("event_data")] - public EventData eventData { get; set; } - - public override string ToString() => - $"{nameof(text)}: {text}, {nameof(checkId)}: {checkId}, {nameof(checkName)}: {checkName}, {nameof(checkUrl)}: {checkUrl}, {nameof(requestTimeout)}: {requestTimeout}, {nameof(requestLocation)}: {requestLocation}, {nameof(requestDatetime)}: {requestDatetime}, {nameof(responseStatusCode)}: {responseStatusCode}, {nameof(responseSummary)}: {responseSummary}, {nameof(responseState)}: {responseState}, {nameof(responseTime)}: {responseTime}, {nameof(eventData)}: {eventData}"; - - public class EventData { - - [JsonPropertyName("org_name")] - public string orgName { get; set; } - - [JsonPropertyName("event_created_on")] - public string eventCreatedOn { get; set; } - - [JsonPropertyName("event_id")] - public int eventId { get; set; } - - [JsonPropertyName("org_id")] - public int orgId { get; set; } - - [JsonPropertyName("webhook_type")] - public string webhookType { get; set; } - - [JsonPropertyName("webhook_id")] - public int webhookId { get; set; } - - public override string ToString() => - $"{nameof(orgName)}: {orgName}, {nameof(eventCreatedOn)}: {eventCreatedOn}, {nameof(eventId)}: {eventId}, {nameof(orgId)}: {orgId}, {nameof(webhookType)}: {webhookType}, {nameof(webhookId)}: {webhookId}"; - - } - -} \ No newline at end of file diff --git a/FreshPager/appsettings.json b/FreshPager/appsettings.json index 5cbdf46..09ecaef 100644 --- a/FreshPager/appsettings.json +++ b/FreshPager/appsettings.json @@ -3,6 +3,7 @@ "My Freshping Service": "", "My Other Freshping Service": "" }, + "httpServerPort": 37374, "logging": { "logLevel": { "Default": "Information", diff --git a/FreshPager/packages.lock.json b/FreshPager/packages.lock.json index f39ea7c..14fd8b9 100644 --- a/FreshPager/packages.lock.json +++ b/FreshPager/packages.lock.json @@ -8,6 +8,15 @@ "resolved": "0.3.0", "contentHash": "XzhQ6cpYMDACcRosaHBO8P8SGAJ2GBC/9Hcb0WUUe9UTUCttr3PrIWf7r+bg6sDCu3/gPWPgh8lfHMTXAi/v3A==" }, + "jaytwo.FluentUri": { + "type": "Direct", + "requested": "[0.1.4, )", + "resolved": "0.1.4", + "contentHash": "94R1yqZgSBbzGBSvMYsePusbanwVdhzaqbYUriwu8DjLEz5E46Gt/Ea6+/BAd13EQwZkxaY9hwAYdUKXrYaj/Q==", + "dependencies": { + "jaytwo.UrlHelper": "0.1.8" + } + }, "Microsoft.Extensions.Hosting.Systemd": { "type": "Direct", "requested": "[8.0.0, )", @@ -28,21 +37,17 @@ "System.ServiceProcess.ServiceController": "8.0.0" } }, - "PagerDuty": { - "type": "Direct", - "requested": "[1.1.0, )", - "resolved": "1.1.0", - "contentHash": "wlaKOSEruJW43+mp8Zjj66ULmRtdoyMROGEGzdZHB9ek4fiyGL2v9xnR+jVHzZ75znJRTITqEXWCXAZ568A13g==", - "dependencies": { - "Newtonsoft.Json": "13.0.3" - } - }, "ThrottleDebounce": { "type": "Direct", "requested": "[2.0.0, )", "resolved": "2.0.0", "contentHash": "/lt2PLUjE1bXCkPDVXXhZDzqaK3SmKwJ2EOq/a6ZbsgAWnRz3TqkqU0VyUncbh8bUIJQHCoPUxbwmjWeAbeIbw==" }, + "jaytwo.UrlHelper": { + "type": "Transitive", + "resolved": "0.1.8", + "contentHash": "QJ7ruofUyY4YNSbLiI+NId/P3PfXttigsdg34h4Hqsx7jhYybHFk7PCFb785OSmC/vZHZC8+Jqoe8L8EHDNLBg==" + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "8.0.0", @@ -325,11 +330,6 @@ "resolved": "8.0.0", "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" - }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", "resolved": "8.0.0", diff --git a/Tests/.editorconfig b/Tests/.editorconfig new file mode 100644 index 0000000..7fc29bf --- /dev/null +++ b/Tests/.editorconfig @@ -0,0 +1,41 @@ +[*] + +# Exception Analyzers: Exception adjustments syntax error +# default = error +; dotnet_diagnostic.Ex0001.severity = none + +# Exception Analyzers: Exception adjustments syntax error: Symbol does not exist or identifier is invalid +# default = warning +; dotnet_diagnostic.Ex0002.severity = none + +# Exception Analyzers: Member may throw undocumented exception +# default = warning +dotnet_diagnostic.Ex0100.severity = none + +# Exception Analyzers: Member accessor may throw undocumented exception +# default = warning +dotnet_diagnostic.Ex0101.severity = none + +# Exception Analyzers: Implicit constructor may throw undocumented exception +# default = warning +dotnet_diagnostic.Ex0103.severity = none + +# Exception Analyzers: Member initializer may throw undocumented exception +# default = warning +dotnet_diagnostic.Ex0104.severity = none + +# Exception Analyzers: Delegate created from member may throw undocumented exception +# default = silent +; dotnet_diagnostic.Ex0120.severity = none + +# Exception Analyzers: Delegate created from anonymous function may throw undocumented exception +# default = silent +; dotnet_diagnostic.Ex0121.severity = none + +# Exception Analyzers: Member is documented as throwing exception not documented on member in base or interface type +# default = warning +dotnet_diagnostic.Ex0200.severity = none + +# Exception Analyzers: Member accessor is documented as throwing exception not documented on member in base or interface type +# default = warning +dotnet_diagnostic.Ex0201.severity = none \ No newline at end of file diff --git a/Tests/GlobalUsings.cs b/Tests/GlobalUsings.cs new file mode 100644 index 0000000..3a57443 --- /dev/null +++ b/Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +// Global using directives + +global using Xunit; \ No newline at end of file diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj new file mode 100644 index 0000000..f16f6ca --- /dev/null +++ b/Tests/Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + \ No newline at end of file diff --git a/Tests/WebhookPayloadTest.cs b/Tests/WebhookPayloadTest.cs new file mode 100644 index 0000000..819fe28 --- /dev/null +++ b/Tests/WebhookPayloadTest.cs @@ -0,0 +1,109 @@ +using FluentAssertions; +using FreshPager.Data; +using System.Text.Json; + +namespace Tests; + +public class WebhookPayloadTest { + + [Fact] + public void deserializeDown() { + // language=json + const string PAYLOAD = + """ + { + "text": "Aldaviva HTTP (https://aldaviva.com) is DOWN.", + "check_id": "36897", + "check_name": "Aldaviva HTTP", + "check_url": "https://aldaviva.com", + "request_timeout": "30", + "request_location": "US East (N. Virginia)", + "request_datetime": "2024-06-28T18:07:46.709971+00:00", + "response_status_code": "None", + "response_summary": "Connection Timeout", + "response_state": "Not Responding", + "response_time": "30003", + "event_data": { + "org_name": "aldaviva", + "event_created_on": "2024-06-28T18:07:46.710438+00:00", + "event_id": 17960760, + "org_id": 10593, + "webhook_type": "AT", + "webhook_id": 35191 + } + } + """; + + WebhookPayload actual = JsonSerializer.Deserialize(PAYLOAD)!; + + actual.eventTitle.Should().Be("Aldaviva HTTP (https://aldaviva.com) is DOWN."); + actual.checkId.Should().Be(36897); + actual.checkName.Should().Be("Aldaviva HTTP"); + actual.checkedUrl.Should().Be("https://aldaviva.com"); + actual.requestTimeout.Should().Be(TimeSpan.FromSeconds(30)); + actual.requestLocation.Should().Be("US East (N. Virginia)"); + actual.requestDateTime.Should().Be(new DateTimeOffset(2024, 6, 28, 18, 7, 46, 709, 971, TimeSpan.Zero)); + actual.responseStatusCode.Should().BeNull(); + actual.responseSummary.Should().Be("Connection Timeout"); + actual.responseState.Should().Be("Not Responding"); + actual.responseTime.Should().Be(TimeSpan.FromMilliseconds(30003)); + actual.organizationSubdomain.Should().Be("aldaviva"); + actual.eventCreationDateTime.Should().Be(new DateTimeOffset(2024, 6, 28, 18, 7, 46, 710, 438, TimeSpan.Zero)); + actual.eventId.Should().Be(17960760); + actual.organizationId.Should().Be(10593); + actual.eventFilter.Should().Be(WebhookPayload.EventFilter.UP_DOWN); + actual.webhookId.Should().Be(35191); + actual.isServiceUp.Should().BeFalse(); + } + + [Fact] + public void deserializeUp() { + // language=json + const string PAYLOAD = + """ + { + "text": "Aldaviva SMTP (tcp://aldaviva.com:25) is UP.", + "check_id": "829684", + "check_name": "Aldaviva SMTP", + "check_url": "tcp://aldaviva.com:25", + "request_timeout": "30", + "request_location": "US East (N. Virginia)", + "request_datetime": "2024-06-28T18:07:46.709971+00:00", + "response_status_code": "1", + "response_summary": "Available", + "response_state": "Available", + "response_time": "17", + "event_data": { + "org_name": "aldaviva", + "event_created_on": "2024-06-28T18:07:46.710438+00:00", + "event_id": 17960894, + "org_id": 10593, + "webhook_type": "AT", + "webhook_id": 35191 + } + } + """; + + WebhookPayload actual = JsonSerializer.Deserialize(PAYLOAD)!; + + actual.eventTitle.Should().Be("Aldaviva SMTP (tcp://aldaviva.com:25) is UP."); + actual.checkId.Should().Be(829684); + actual.checkName.Should().Be("Aldaviva SMTP"); + actual.checkedUrl.Should().Be("tcp://aldaviva.com:25"); + actual.requestTimeout.Should().Be(TimeSpan.FromSeconds(30)); + actual.requestLocation.Should().Be("US East (N. Virginia)"); + actual.requestDateTime.Should().Be(new DateTimeOffset(2024, 6, 28, 18, 7, 46, 709, 971, TimeSpan.Zero)); + actual.responseStatusCode.Should().Be(1); + actual.responseSummary.Should().Be("Available"); + actual.responseState.Should().Be("Available"); + actual.responseTime.Should().Be(TimeSpan.FromMilliseconds(17)); + actual.organizationSubdomain.Should().Be("aldaviva"); + actual.eventCreationDateTime.Should().Be(new DateTimeOffset(2024, 6, 28, 18, 7, 46, 710, 438, TimeSpan.Zero)); + actual.eventId.Should().Be(17960894); + actual.organizationId.Should().Be(10593); + actual.eventFilter.Should().Be(WebhookPayload.EventFilter.UP_DOWN); + actual.webhookId.Should().Be(35191); + actual.isServiceUp.Should().BeTrue(); + } + +} \ No newline at end of file