diff --git a/infra/main.bicep b/infra/main.bicep index d2964ec..fe3342f 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -67,6 +67,20 @@ module keyvault 'modules/keyvault.bicep' = { } } +// Notification Hubs Free tier — namespace + hub. The connection string is +// consumed by the Function App as an app setting (DeviceApi for installation +// upserts; Phase-2+ PushDelivery for sends). APNs .p8 upload remains manual +// — Bicep doesn't accept the file contents directly. +module notificationHub 'modules/notificationhub.bicep' = { + name: 'notification-hub-${env}' + params: { + location: location + namePrefix: namePrefix + env: env + tags: tags + } +} + // Function App + Storage + App Insights (all five Functions live here). module functions 'modules/functions.bicep' = { name: 'functions-${env}' @@ -77,6 +91,8 @@ module functions 'modules/functions.bicep' = { tags: tags cosmosAccountName: cosmos.outputs.accountName keyVaultName: keyvault.outputs.vaultName + notificationHubConnectionString: notificationHub.outputs.hubConnectionString + notificationHubName: notificationHub.outputs.hubName } } @@ -94,18 +110,6 @@ module eventgrid 'modules/eventgrid.bicep' = { } } -// ── PHASE 2 ───────────────────────────────────────────────────────── -// Notification Hubs Free tier — namespace + hub. -module notificationHub 'modules/notificationhub.bicep' = { - name: 'notification-hub-${env}' - params: { - location: location - namePrefix: namePrefix - env: env - tags: tags - } -} - // ── outputs consumed by GitHub Actions cd-deploy.yml ──────────────── output functionAppName string = functions.outputs.functionAppName output functionAppHostname string = functions.outputs.defaultHostname diff --git a/infra/modules/functions.bicep b/infra/modules/functions.bicep index 11b638e..975bdd8 100644 --- a/infra/modules/functions.bicep +++ b/infra/modules/functions.bicep @@ -22,6 +22,13 @@ param cosmosAccountName string @description('Key Vault name (for @Microsoft.KeyVault references).') param keyVaultName string +@description('Notification Hub full SAS connection string (DeviceApi + PushDelivery).') +@secure() +param notificationHubConnectionString string + +@description('Notification Hub name (DeviceApi + PushDelivery).') +param notificationHubName string + var storageName = toLower('st${namePrefix}${env}${uniqueString(resourceGroup().id)}') var planName = 'plan-${namePrefix}-${env}' var appInsightsName = 'appi-${namePrefix}-${env}' @@ -119,6 +126,11 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = { { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString } { name: 'COSMOS_ACCOUNT_NAME', value: cosmosAccountName } { name: 'KEY_VAULT_NAME', value: keyVaultName } + // DeviceApi (Notify.DeviceApi/DeviceApiOptions.cs) reads these via + // ConfigureFunctionsWorkerDefaults binding. PushDelivery will consume + // the same pair when it lands. + { name: 'NotificationHubConnectionString', value: notificationHubConnectionString } + { name: 'NotificationHubName', value: notificationHubName } ] } } diff --git a/infra/modules/notificationhub.bicep b/infra/modules/notificationhub.bicep index 9039322..e5c619e 100644 --- a/infra/modules/notificationhub.bicep +++ b/infra/modules/notificationhub.bicep @@ -33,5 +33,17 @@ resource hub 'Microsoft.NotificationHubs/namespaces/notificationHubs@2023-09-01' properties: {} } +// Default authorization rule with Manage permissions — created automatically +// alongside every NH. We listKeys against it to surface the connection string +// to consumers (DeviceApi for CreateOrUpdateInstallation, PushDelivery for +// SendNotification). Treat the output as sensitive — only flow it to other +// modules' app settings, never log it. +resource defaultRule 'Microsoft.NotificationHubs/namespaces/notificationHubs/AuthorizationRules@2023-09-01' existing = { + parent: hub + name: 'DefaultFullSharedAccessSignature' +} + output namespaceName string = namespace.name output hubName string = hub.name +@secure() +output hubConnectionString string = defaultRule.listKeys().primaryConnectionString diff --git a/src/Notify.DeviceApi/DeviceApiOptions.cs b/src/Notify.DeviceApi/DeviceApiOptions.cs new file mode 100644 index 0000000..d4775ba --- /dev/null +++ b/src/Notify.DeviceApi/DeviceApiOptions.cs @@ -0,0 +1,14 @@ +namespace Notify.DeviceApi; + +// Strongly-typed bindings for the app settings DeviceApi reads at startup. +// Configured by infra/modules/functions.bicep; local.settings.json mirrors +// it for local dev. NotificationHubConnectionString is a Key Vault reference +// at deploy time (full SAS — DeviceApi needs Manage permissions to +// CreateOrUpdate Installations). +public sealed record DeviceApiOptions +{ + public required string NotificationHubConnectionString { get; init; } + public required string NotificationHubName { get; init; } + + public const int MaxRequestBodyBytes = 4 * 1024; +} diff --git a/src/Notify.DeviceApi/Devices/DeviceRegistration.cs b/src/Notify.DeviceApi/Devices/DeviceRegistration.cs new file mode 100644 index 0000000..9701cf6 --- /dev/null +++ b/src/Notify.DeviceApi/Devices/DeviceRegistration.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Notify.DeviceApi.Devices; + +// Registration request from the iOS app. The platform field is fixed to +// "apns" for v1 — Android can land later as a sibling enum value. +public sealed record DeviceRegistration +{ + [JsonPropertyName("deviceToken")] + public required string DeviceToken { get; init; } + + [JsonPropertyName("platform")] + public required string Platform { get; init; } + + [JsonPropertyName("tags")] + public IReadOnlyList? Tags { get; init; } +} diff --git a/src/Notify.DeviceApi/Devices/DeviceRegistrationValidator.cs b/src/Notify.DeviceApi/Devices/DeviceRegistrationValidator.cs new file mode 100644 index 0000000..c963dbc --- /dev/null +++ b/src/Notify.DeviceApi/Devices/DeviceRegistrationValidator.cs @@ -0,0 +1,62 @@ +using Notify.Shared.Validation; + +namespace Notify.DeviceApi.Devices; + +// Lightweight validator — the heavy contract validation lives in Notify.Shared +// (NotifyCreatedV1Validator). This keeps the DeviceApi self-contained until +// the iOS Codable side wants to share validation in Phase 3. +public static class DeviceRegistrationValidator +{ + public const int MaxTagCount = 50; + public const int MaxTagLength = 120; + // APNs device tokens are 32 bytes (64 hex chars) historically; newer + // device tokens (iOS 13+) can be 100+ bytes. Cap at 256 hex chars to leave + // room without inviting abuse. + public const int MinTokenLength = 64; + public const int MaxTokenLength = 256; + + public static ValidationResult Validate(DeviceRegistration r) + { + var failures = new List(); + + if (string.IsNullOrWhiteSpace(r.DeviceToken)) + failures.Add(new ValidationFailure(nameof(r.DeviceToken), "required")); + else if (r.DeviceToken.Length < MinTokenLength || r.DeviceToken.Length > MaxTokenLength) + failures.Add(new ValidationFailure(nameof(r.DeviceToken), $"length must be {MinTokenLength}..{MaxTokenLength} chars")); + else if (!IsHex(r.DeviceToken)) + failures.Add(new ValidationFailure(nameof(r.DeviceToken), "must be hex")); + + if (string.IsNullOrWhiteSpace(r.Platform)) + failures.Add(new ValidationFailure(nameof(r.Platform), "required")); + else if (!string.Equals(r.Platform, "apns", StringComparison.OrdinalIgnoreCase)) + failures.Add(new ValidationFailure(nameof(r.Platform), "only 'apns' is supported in v1")); + + if (r.Tags is { Count: > MaxTagCount }) + failures.Add(new ValidationFailure(nameof(r.Tags), $"at most {MaxTagCount} tags")); + + if (r.Tags is not null) + { + for (var i = 0; i < r.Tags.Count; i++) + { + var tag = r.Tags[i]; + if (string.IsNullOrWhiteSpace(tag)) + failures.Add(new ValidationFailure($"{nameof(r.Tags)}[{i}]", "empty")); + else if (tag.Length > MaxTagLength) + failures.Add(new ValidationFailure($"{nameof(r.Tags)}[{i}]", $"length must be ≤ {MaxTagLength} chars")); + } + } + + return new ValidationResult(failures); + } + + private static bool IsHex(string s) + { + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + var ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!ok) return false; + } + return true; + } +} diff --git a/src/Notify.DeviceApi/Devices/RegisterHandler.cs b/src/Notify.DeviceApi/Devices/RegisterHandler.cs new file mode 100644 index 0000000..bf0f080 --- /dev/null +++ b/src/Notify.DeviceApi/Devices/RegisterHandler.cs @@ -0,0 +1,60 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Azure.NotificationHubs; +using Notify.Shared.Json; +using Notify.Shared.Validation; + +namespace Notify.DeviceApi.Devices; + +// Pure registration logic; the Function class is a thin HTTP shim around it. +// NH calls go through INotificationHub so unit tests don't need a real hub. +public sealed class RegisterHandler +{ + private readonly INotificationHub _hub; + + public RegisterHandler(INotificationHub hub) => _hub = hub; + + public async Task HandleAsync(Stream body, long? contentLength, CancellationToken ct = default) + { + if (contentLength is > DeviceApiOptions.MaxRequestBodyBytes) + return new RegisterResult.PayloadTooLarge(DeviceApiOptions.MaxRequestBodyBytes); + + DeviceRegistration? input; + try + { + input = await JsonSerializer.DeserializeAsync(body, NotifyJson.Options, ct); + } + catch (JsonException ex) + { + return new RegisterResult.BadRequest(new[] { new ValidationFailure("body", ex.Message) }); + } + + if (input is null) + return new RegisterResult.BadRequest(new[] { new ValidationFailure("body", "missing") }); + + var validation = DeviceRegistrationValidator.Validate(input); + if (!validation.IsValid) + return new RegisterResult.BadRequest(validation.Failures); + + var installationId = InstallationIdFor(input.DeviceToken); + var installation = new Installation + { + InstallationId = installationId, + Platform = NotificationPlatform.Apns, + PushChannel = input.DeviceToken, + Tags = input.Tags is null ? null : new List(input.Tags), + }; + + await _hub.UpsertInstallationAsync(installation, ct); + return new RegisterResult.Accepted(installationId); + } + + // Deterministic installation id keyed on the device token so re-registration + // is idempotent (CreateOrUpdate semantics on NH). + public static string InstallationIdFor(string deviceToken) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(deviceToken)); + return Convert.ToHexStringLower(hash); + } +} diff --git a/src/Notify.DeviceApi/Devices/RegisterResult.cs b/src/Notify.DeviceApi/Devices/RegisterResult.cs new file mode 100644 index 0000000..a1222de --- /dev/null +++ b/src/Notify.DeviceApi/Devices/RegisterResult.cs @@ -0,0 +1,12 @@ +using Notify.Shared.Validation; + +namespace Notify.DeviceApi.Devices; + +// Discriminated result of a single device-registration attempt; the Function +// maps it to the right HTTP status. Mirrors IngestResult in Notify.IngestionApi. +public abstract record RegisterResult +{ + public sealed record Accepted(string InstallationId) : RegisterResult; + public sealed record BadRequest(IReadOnlyList Failures) : RegisterResult; + public sealed record PayloadTooLarge(int LimitBytes) : RegisterResult; +} diff --git a/src/Notify.DeviceApi/Functions/RegisterDeviceFunction.cs b/src/Notify.DeviceApi/Functions/RegisterDeviceFunction.cs new file mode 100644 index 0000000..9809642 --- /dev/null +++ b/src/Notify.DeviceApi/Functions/RegisterDeviceFunction.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Notify.DeviceApi.Devices; +using Notify.Shared.Json; + +namespace Notify.DeviceApi.Functions; + +// Thin HTTP shim around RegisterHandler. AuthorizationLevel.Function uses the +// Function App's per-function key — the iOS app fetches it once via TestFlight +// build configuration. Project-key auth (npk_*) is not used here because +// devices are user-owned, not project-scoped. +public sealed class RegisterDeviceFunction +{ + private readonly RegisterHandler _handler; + private readonly ILogger _logger; + + public RegisterDeviceFunction(RegisterHandler handler, ILogger logger) + { + _handler = handler; + _logger = logger; + } + + [Function("RegisterDevice")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "v1/devices")] + HttpRequestData req) + { + var contentLength = req.Headers.TryGetValues("content-length", out var clen) && long.TryParse(clen.FirstOrDefault(), out var cl) ? cl : (long?)null; + + var result = await _handler.HandleAsync(req.Body, contentLength); + + return result switch + { + RegisterResult.Accepted a => await Json(req, HttpStatusCode.Accepted, new { installationId = a.InstallationId }), + RegisterResult.BadRequest b => await Json(req, HttpStatusCode.BadRequest, new { errors = b.Failures }), + RegisterResult.PayloadTooLarge p => await Json(req, HttpStatusCode.RequestEntityTooLarge, new { error = $"payload exceeds {p.LimitBytes} bytes" }), + _ => throw new InvalidOperationException($"Unexpected result type {result.GetType()}"), + }; + } + + private static async Task Json(HttpRequestData req, HttpStatusCode status, object body) + { + var resp = req.CreateResponse(status); + resp.Headers.Add("content-type", "application/json; charset=utf-8"); + await resp.WriteStringAsync(JsonSerializer.Serialize(body, NotifyJson.Options)); + return resp; + } +} diff --git a/src/Notify.DeviceApi/INotificationHub.cs b/src/Notify.DeviceApi/INotificationHub.cs new file mode 100644 index 0000000..517d586 --- /dev/null +++ b/src/Notify.DeviceApi/INotificationHub.cs @@ -0,0 +1,20 @@ +using Microsoft.Azure.NotificationHubs; + +namespace Notify.DeviceApi; + +// Seam between the handler and Microsoft.Azure.NotificationHubs.NotificationHubClient +// so unit tests can record installations without standing up a real hub. +public interface INotificationHub +{ + Task UpsertInstallationAsync(Installation installation, CancellationToken ct = default); +} + +public sealed class NotificationHubAdapter : INotificationHub +{ + private readonly NotificationHubClient _client; + + public NotificationHubAdapter(NotificationHubClient client) => _client = client; + + public Task UpsertInstallationAsync(Installation installation, CancellationToken ct = default) + => _client.CreateOrUpdateInstallationAsync(installation, ct); +} diff --git a/src/Notify.DeviceApi/Notify.DeviceApi.csproj b/src/Notify.DeviceApi/Notify.DeviceApi.csproj new file mode 100644 index 0000000..06168ca --- /dev/null +++ b/src/Notify.DeviceApi/Notify.DeviceApi.csproj @@ -0,0 +1,41 @@ + + + + net10.0 + v4 + Exe + enable + enable + true + Notify.DeviceApi + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + diff --git a/src/Notify.DeviceApi/Program.cs b/src/Notify.DeviceApi/Program.cs new file mode 100644 index 0000000..b452f60 --- /dev/null +++ b/src/Notify.DeviceApi/Program.cs @@ -0,0 +1,26 @@ +using Microsoft.Azure.NotificationHubs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Notify.DeviceApi; +using Notify.DeviceApi.Devices; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .ConfigureServices((ctx, services) => + { + services.AddOptions().Bind(ctx.Configuration); + + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService>().Value; + return NotificationHubClient.CreateClientFromConnectionString(opts.NotificationHubConnectionString, opts.NotificationHubName); + }); + + services.AddSingleton(); + services.AddSingleton(); + }) + .Build(); + +host.Run(); diff --git a/src/Notify.DeviceApi/host.json b/src/Notify.DeviceApi/host.json new file mode 100644 index 0000000..cd509c2 --- /dev/null +++ b/src/Notify.DeviceApi/host.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensions": { + "http": { + "routePrefix": "" + } + } +} diff --git a/src/Notify.DeviceApi/local.settings.json.example b/src/Notify.DeviceApi/local.settings.json.example new file mode 100644 index 0000000..e060d60 --- /dev/null +++ b/src/Notify.DeviceApi/local.settings.json.example @@ -0,0 +1,9 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "NotificationHubConnectionString": "Endpoint=sb://.servicebus.windows.net/;SharedAccessKeyName=DefaultFullSharedAccessSignature;SharedAccessKey=", + "NotificationHubName": "nh-notify-dev" + } +} diff --git a/src/Notify.slnx b/src/Notify.slnx index a619b4d..8003c1a 100644 --- a/src/Notify.slnx +++ b/src/Notify.slnx @@ -3,9 +3,11 @@ + + diff --git a/src/tests/Notify.DeviceApi.Tests/Fakes.cs b/src/tests/Notify.DeviceApi.Tests/Fakes.cs new file mode 100644 index 0000000..d9012a1 --- /dev/null +++ b/src/tests/Notify.DeviceApi.Tests/Fakes.cs @@ -0,0 +1,23 @@ +using Microsoft.Azure.NotificationHubs; +using Notify.DeviceApi; + +namespace Notify.DeviceApi.Tests; + +internal sealed class RecordingHub : INotificationHub +{ + public List Upserted { get; } = new(); + + public Task UpsertInstallationAsync(Installation installation, CancellationToken ct = default) + { + Upserted.Add(installation); + return Task.CompletedTask; + } +} + +internal sealed class ThrowingHub : INotificationHub +{ + private readonly Exception _ex; + public ThrowingHub(Exception ex) => _ex = ex; + public Task UpsertInstallationAsync(Installation installation, CancellationToken ct = default) + => Task.FromException(_ex); +} diff --git a/src/tests/Notify.DeviceApi.Tests/Notify.DeviceApi.Tests.csproj b/src/tests/Notify.DeviceApi.Tests/Notify.DeviceApi.Tests.csproj new file mode 100644 index 0000000..51a3183 --- /dev/null +++ b/src/tests/Notify.DeviceApi.Tests/Notify.DeviceApi.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/src/tests/Notify.DeviceApi.Tests/RegisterHandlerTests.cs b/src/tests/Notify.DeviceApi.Tests/RegisterHandlerTests.cs new file mode 100644 index 0000000..929964d --- /dev/null +++ b/src/tests/Notify.DeviceApi.Tests/RegisterHandlerTests.cs @@ -0,0 +1,131 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Azure.NotificationHubs; +using Notify.DeviceApi; +using Notify.DeviceApi.Devices; +using Notify.Shared.Json; + +namespace Notify.DeviceApi.Tests; + +public class RegisterHandlerTests +{ + private const string ValidToken = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; // 64 hex chars + + private static (RegisterHandler handler, RecordingHub hub) NewHandler() + { + var hub = new RecordingHub(); + return (new RegisterHandler(hub), hub); + } + + private static Stream BodyOf(object obj) + => new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(obj, NotifyJson.Options))); + + private static object ValidBody(string token = ValidToken, string platform = "apns", IReadOnlyList? tags = null) => new + { + deviceToken = token, + platform, + tags, + }; + + [Fact] + public async Task Oversized_payload_returns_413_without_parsing_body() + { + var (handler, _) = NewHandler(); + var result = await handler.HandleAsync(Stream.Null, contentLength: DeviceApiOptions.MaxRequestBodyBytes + 1); + Assert.IsType(result); + } + + [Fact] + public async Task Malformed_json_returns_bad_request() + { + var (handler, _) = NewHandler(); + var bad = new MemoryStream(Encoding.UTF8.GetBytes("{not json")); + var result = await handler.HandleAsync(bad, contentLength: null); + var br = Assert.IsType(result); + Assert.Contains(br.Failures, f => f.Field == "body"); + } + + [Fact] + public async Task Missing_device_token_returns_bad_request() + { + var (handler, hub) = NewHandler(); + var body = new { deviceToken = "", platform = "apns" }; + var result = await handler.HandleAsync(BodyOf(body), contentLength: null); + Assert.IsType(result); + Assert.Empty(hub.Upserted); + } + + [Fact] + public async Task Non_hex_device_token_returns_bad_request() + { + var (handler, _) = NewHandler(); + // Same length as ValidToken but with a non-hex char. + var token = new string('z', 64); + var result = await handler.HandleAsync(BodyOf(ValidBody(token)), contentLength: null); + var br = Assert.IsType(result); + Assert.Contains(br.Failures, f => f.Field == "DeviceToken" && f.Message.Contains("hex")); + } + + [Fact] + public async Task Unsupported_platform_returns_bad_request() + { + var (handler, _) = NewHandler(); + var result = await handler.HandleAsync(BodyOf(ValidBody(platform: "fcm")), contentLength: null); + var br = Assert.IsType(result); + Assert.Contains(br.Failures, f => f.Field == "Platform"); + } + + [Fact] + public async Task Too_many_tags_returns_bad_request() + { + var (handler, _) = NewHandler(); + var tags = Enumerable.Range(0, DeviceRegistrationValidator.MaxTagCount + 1).Select(i => $"t{i}").ToArray(); + var result = await handler.HandleAsync(BodyOf(ValidBody(tags: tags)), contentLength: null); + var br = Assert.IsType(result); + Assert.Contains(br.Failures, f => f.Field == "Tags"); + } + + [Fact] + public async Task Happy_path_upserts_installation_and_returns_id() + { + var (handler, hub) = NewHandler(); + var tags = new[] { "source:home-pipeline", "global" }; + + var result = await handler.HandleAsync(BodyOf(ValidBody(tags: tags)), contentLength: null); + + var accepted = Assert.IsType(result); + Assert.Equal(RegisterHandler.InstallationIdFor(ValidToken), accepted.InstallationId); + + var inst = Assert.Single(hub.Upserted); + Assert.Equal(accepted.InstallationId, inst.InstallationId); + Assert.Equal(NotificationPlatform.Apns, inst.Platform); + Assert.Equal(ValidToken, inst.PushChannel); + Assert.NotNull(inst.Tags); + Assert.Equal(tags, inst.Tags); + } + + [Fact] + public async Task Same_token_produces_same_installation_id_idempotency() + { + var (handler, hub) = NewHandler(); + + await handler.HandleAsync(BodyOf(ValidBody()), contentLength: null); + await handler.HandleAsync(BodyOf(ValidBody()), contentLength: null); + + Assert.Equal(2, hub.Upserted.Count); + Assert.Equal(hub.Upserted[0].InstallationId, hub.Upserted[1].InstallationId); + } + + [Fact] + public async Task Tokens_are_case_insensitive_but_id_is_not() + { + // Validator accepts both A-F and a-f; the deterministic id treats the + // bytes literally, so callers should pick one casing and stick with it. + var (handler, hub) = NewHandler(); + await handler.HandleAsync(BodyOf(ValidBody(ValidToken.ToUpperInvariant())), contentLength: null); + await handler.HandleAsync(BodyOf(ValidBody(ValidToken.ToLowerInvariant())), contentLength: null); + + Assert.NotEqual(hub.Upserted[0].InstallationId, hub.Upserted[1].InstallationId); + } +}