mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
e816c92b88
A tenant configuration row is never written blind any more (D-290). Three writers saved
from the 5-minute instance cache and four wrote a fresh read without an ETag: auto-re-enable
could lift a suspension a Global Admin had just renewed, the PUT could write the cached
Disabled=false over the offboarding tombstone during the drain barrier, and a concurrent
first login could overwrite the write-once OnboardedBy.
- TenantConfigurationService.CreateOrUpdateAsync next to UpdateAsync (shared core): fresh
read with ETag, tombstone refused, mutation decides, conditional replace, conditional
insert for a missing row, re-read and re-decide on a lost race.
- SaveConfigurationAsync and IConfigRepository.SaveTenantConfigurationAsync deleted; new
TryCreateTenantConfigurationAsync. The default row of GetConfigurationAsync is a
conditional insert.
- Converted: first-login seed (writes only while the stored row has no domain; only the
winning login fires the signup side effects), auto-re-enable (decided on the fresh row),
auth client-id tracking, app-homing flip, conferred-Pro grace stamp, offboarding tombstone.
- PUT/POST config/{tenantId} removed (function, catalog entries, response DTO). The Global
Admin tenant editor sends only its changed fields to the field PATCH; the Collect Logs
quick-config PATCHes its two diagnostics fields with ?intent=collect-logs, which keeps
the CollectLogsQuickConfigEnabled ops event. Shared diff helper lib/tenantConfigSave.ts.
- Tests: create-or-update and default-row races, concurrent first login, renewed
suspension, tombstone paths; repository and service doubles in ConfigRepoMockExtensions.
Regenerated shared manifest and wire types (web, MCP).
Deploy order: backend, then web right after (the old web's editor save and quick-config
use the removed PUT).
Bumps internal (D-290, plan, docs).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
299 lines
13 KiB
C#
299 lines
13 KiB
C#
using AutopilotMonitor.Functions.Functions.Config;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Models.Notifications;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Multi-channel notification config: JSON parse hardening, legacy single-webhook synthesis
|
|
/// (behavior parity for non-migrated tenants), and per-channel secret redaction/restore.
|
|
/// </summary>
|
|
public class NotificationChannelsTests
|
|
{
|
|
private static string ChannelJson(string id, int providerType = 20, string url = "https://hooks.example/x",
|
|
string extra = "")
|
|
=> $"{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":true{extra}}}";
|
|
|
|
// ── ParseList hardening ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ParseList_NullEmptyOrMalformed_YieldsEmpty()
|
|
{
|
|
Assert.Empty(NotificationChannel.ParseList(null));
|
|
Assert.Empty(NotificationChannel.ParseList(""));
|
|
Assert.Empty(NotificationChannel.ParseList(" "));
|
|
Assert.Empty(NotificationChannel.ParseList("not-json"));
|
|
Assert.Empty(NotificationChannel.ParseList("{\"id\":\"obj-not-array\"}"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseList_DropsEntriesWithoutIdOrWithInvalidProvider()
|
|
{
|
|
var json = "[" + string.Join(",",
|
|
ChannelJson("ok", providerType: 10),
|
|
"{\"name\":\"no-id\",\"providerType\":20,\"url\":\"https://x.example\"}",
|
|
ChannelJson("none-provider", providerType: 0),
|
|
ChannelJson("unknown-provider", providerType: 99)) + "]";
|
|
|
|
var parsed = NotificationChannel.ParseList(json);
|
|
|
|
Assert.Single(parsed);
|
|
Assert.Equal("ok", parsed[0].Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseList_CapsAtMaxChannelsPerTenant()
|
|
{
|
|
var entries = Enumerable.Range(0, NotificationChannel.MaxChannelsPerTenant + 5)
|
|
.Select(i => ChannelJson($"ch-{i}"));
|
|
var parsed = NotificationChannel.ParseList("[" + string.Join(",", entries) + "]");
|
|
|
|
Assert.Equal(NotificationChannel.MaxChannelsPerTenant, parsed.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetCustomHeaders_OnlyForGenericJson()
|
|
{
|
|
var headers = "{\"X-Api-Key\":\"k\",\"Host\":\"evil\"}";
|
|
var generic = new NotificationChannel { Id = "g", ProviderType = 20, CustomHeadersJson = headers };
|
|
var slack = new NotificationChannel { Id = "s", ProviderType = 10, CustomHeadersJson = headers };
|
|
|
|
Assert.Equal("k", generic.GetCustomHeaders()["X-Api-Key"]);
|
|
Assert.False(generic.GetCustomHeaders().ContainsKey("Host")); // restricted header dropped
|
|
Assert.Empty(slack.GetCustomHeaders());
|
|
}
|
|
|
|
[Fact]
|
|
public void GetSigningSecret_OnlyForGenericJson()
|
|
{
|
|
var secret = "0123456789abcdef";
|
|
Assert.Equal(secret, new NotificationChannel { Id = "g", ProviderType = 20, SigningSecret = secret }.GetSigningSecret());
|
|
Assert.Null(new NotificationChannel { Id = "s", ProviderType = 10, SigningSecret = secret }.GetSigningSecret());
|
|
Assert.Null(new NotificationChannel { Id = "d", ProviderType = 30, SigningSecret = secret }.GetSigningSecret());
|
|
Assert.Null(new NotificationChannel { Id = "w", ProviderType = 20, SigningSecret = " " }.GetSigningSecret());
|
|
Assert.Null(new NotificationChannel { Id = "n", ProviderType = 20 }.GetSigningSecret());
|
|
}
|
|
|
|
// ── Legacy synthesis (GetNotificationChannels) ────────────────────────
|
|
|
|
[Fact]
|
|
public void GetNotificationChannels_NoWebhookAtAll_Empty()
|
|
{
|
|
Assert.Empty(new TenantConfiguration().GetNotificationChannels());
|
|
}
|
|
|
|
[Fact]
|
|
public void GetNotificationChannels_LegacyWebhookFields_SynthesizesOneChannelWithParity()
|
|
{
|
|
var cfg = new TenantConfiguration
|
|
{
|
|
WebhookProviderType = 20,
|
|
WebhookUrl = "https://desk.example/hook",
|
|
WebhookCustomHeadersJson = "{\"X-Api-Key\":\"k\"}",
|
|
WebhookNotifyOnSuccess = false,
|
|
WebhookNotifyOnFailure = true,
|
|
WebhookNotifyOnStart = true,
|
|
WebhookNotifyOnHardwareRejection = true,
|
|
};
|
|
|
|
var channels = cfg.GetNotificationChannels();
|
|
|
|
var ch = Assert.Single(channels);
|
|
Assert.Equal(TenantConfiguration.LegacyChannelId, ch.Id);
|
|
Assert.Equal(20, ch.ProviderType);
|
|
Assert.Equal("https://desk.example/hook", ch.Url);
|
|
Assert.True(ch.Enabled);
|
|
Assert.False(ch.NotifyOnSuccess);
|
|
Assert.True(ch.NotifyOnFailure);
|
|
Assert.True(ch.NotifyOnStart);
|
|
Assert.True(ch.NotifyOnHardwareRejection);
|
|
Assert.True(ch.NotifyOnSlaEvents); // legacy: SLA alerts always went to the single webhook
|
|
Assert.Equal("k", ch.GetCustomHeaders()["X-Api-Key"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetNotificationChannels_LegacyTeamsOnly_SynthesizesTeamsLegacyConnector()
|
|
{
|
|
var cfg = new TenantConfiguration
|
|
{
|
|
TeamsWebhookUrl = "https://teams.example/hook",
|
|
TeamsNotifyOnSuccess = true,
|
|
TeamsNotifyOnFailure = false,
|
|
};
|
|
|
|
var ch = Assert.Single(cfg.GetNotificationChannels());
|
|
Assert.Equal((int)WebhookProviderType.TeamsLegacyConnector, ch.ProviderType);
|
|
Assert.Equal("https://teams.example/hook", ch.Url);
|
|
Assert.True(ch.NotifyOnSuccess);
|
|
Assert.False(ch.NotifyOnFailure);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetNotificationChannels_ChannelsJsonWinsOverLegacyFields()
|
|
{
|
|
var cfg = new TenantConfiguration
|
|
{
|
|
WebhookProviderType = 20,
|
|
WebhookUrl = "https://legacy.example/hook",
|
|
NotificationChannelsJson = "[" + ChannelJson("ch-1", providerType: 10, url: "https://slack.example/h") + "]",
|
|
};
|
|
|
|
var ch = Assert.Single(cfg.GetNotificationChannels());
|
|
Assert.Equal("ch-1", ch.Id);
|
|
Assert.Equal("https://slack.example/h", ch.Url);
|
|
}
|
|
|
|
// ── Redaction / restore ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void RedactedCopy_RedactsPerChannelSecretsButKeepsStructure()
|
|
{
|
|
var cfg = new TenantConfiguration
|
|
{
|
|
NotificationChannelsJson = "[" +
|
|
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\",\"signingSecret\":\"0123456789abcdef\"") + "," +
|
|
ChannelJson("ch-2", providerType: 10, url: "https://slack.example/h") + "]",
|
|
};
|
|
|
|
var redacted = NotificationChannel.ParseList(cfg.RedactedCopyForReader().NotificationChannelsJson);
|
|
|
|
Assert.Equal(2, redacted.Count);
|
|
Assert.Equal("ch-1", redacted[0].Id); // structure visible
|
|
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url); // secrets masked
|
|
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].CustomHeadersJson);
|
|
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].SigningSecret);
|
|
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[1].Url);
|
|
Assert.Null(redacted[1].CustomHeadersJson); // unset stays unset
|
|
Assert.Null(redacted[1].SigningSecret);
|
|
}
|
|
|
|
[Fact]
|
|
public void RedactedCopy_UnparseableChannelsJson_FallsBackToWholeStringRedaction()
|
|
{
|
|
var cfg = new TenantConfiguration { NotificationChannelsJson = "not-json-but-maybe-secret" };
|
|
Assert.Equal(Constants.RedactedSecretPlaceholder, cfg.RedactedCopyForReader().NotificationChannelsJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void RestoreRedactedSecrets_RestoresChannelSecretsById()
|
|
{
|
|
var existing = new TenantConfiguration
|
|
{
|
|
NotificationChannelsJson = "[" +
|
|
ChannelJson("ch-1", url: "https://real.example/hook",
|
|
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\",\"signingSecret\":\"real-secret-0123\"") + "]",
|
|
};
|
|
|
|
// Round-trip the redacted view back onto a save (defense-in-depth path).
|
|
var incoming = existing.RedactedCopyForReader();
|
|
incoming.RestoreRedactedSecretsFrom(existing);
|
|
|
|
var ch = Assert.Single(NotificationChannel.ParseList(incoming.NotificationChannelsJson));
|
|
Assert.Equal("https://real.example/hook", ch.Url);
|
|
Assert.Equal("{\"X-Api-Key\":\"real\"}", ch.CustomHeadersJson);
|
|
Assert.Equal("real-secret-0123", ch.SigningSecret);
|
|
}
|
|
|
|
[Fact]
|
|
public void RestoreRedactedSecrets_KeepsGenuineChannelEdits()
|
|
{
|
|
var existing = new TenantConfiguration
|
|
{
|
|
NotificationChannelsJson = "[" + ChannelJson("ch-1", url: "https://old.example/hook") + "]",
|
|
};
|
|
var incoming = new TenantConfiguration
|
|
{
|
|
// Genuine edit of ch-1 plus a brand-new ch-2: nothing carries the sentinel,
|
|
// so restore must not touch anything.
|
|
NotificationChannelsJson = "[" + ChannelJson("ch-1", url: "https://NEW.example/hook") + ","
|
|
+ ChannelJson("ch-2", url: "https://second.example/hook") + "]",
|
|
};
|
|
|
|
incoming.RestoreRedactedSecretsFrom(existing);
|
|
|
|
var channels = NotificationChannel.ParseList(incoming.NotificationChannelsJson);
|
|
Assert.Equal("https://NEW.example/hook", channels[0].Url);
|
|
Assert.Equal("https://second.example/hook", channels[1].Url);
|
|
}
|
|
|
|
// ── Save-time validation (TenantConfigValidation) ─────────
|
|
|
|
[Fact]
|
|
public void ValidateNotificationChannels_NullEmptyOrValidList_PassesThrough()
|
|
{
|
|
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(null));
|
|
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(""));
|
|
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
|
|
"[" + ChannelJson("ch-1") + "," + ChannelJson("ch-2", providerType: 10) + "]"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("not-json", "not valid JSON")]
|
|
[InlineData("[{\"name\":\"no-id\",\"providerType\":20}]", "needs an id")]
|
|
[InlineData("[{\"id\":\"a\",\"providerType\":99}]", "invalid provider type")]
|
|
[InlineData("[{\"id\":\"a\",\"providerType\":20,\"url\":\"http://plain.example\"}]", "")] // non-https rejected by SsrfGuard format check
|
|
public void ValidateNotificationChannels_RejectsInvalidEntries(string json, string expectedFragment)
|
|
{
|
|
var error = TenantConfigValidation.ValidateNotificationChannels(json);
|
|
Assert.NotNull(error);
|
|
if (expectedFragment.Length > 0)
|
|
Assert.Contains(expectedFragment, error);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateNotificationChannels_RejectsDuplicateIds()
|
|
{
|
|
var error = TenantConfigValidation.ValidateNotificationChannels(
|
|
"[" + ChannelJson("ch-1") + "," + ChannelJson("ch-1") + "]");
|
|
Assert.NotNull(error);
|
|
Assert.Contains("duplicate", error);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateNotificationChannels_RejectsBadPerChannelHeaders()
|
|
{
|
|
var json = "[" + ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"not-json\"") + "]";
|
|
var error = TenantConfigValidation.ValidateNotificationChannels(json);
|
|
Assert.NotNull(error);
|
|
Assert.Contains("headers", error);
|
|
}
|
|
|
|
// ── Signing-secret validation ─────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("0123456789abcdef")] // 16 chars (minimum)
|
|
public void ValidateWebhookSigningSecret_AcceptsAbsentOrValid(string? secret)
|
|
{
|
|
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
|
|
// 128 chars (maximum) and a typical generated 64-char hex secret
|
|
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 128)));
|
|
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('f', 64)));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("short-15-chars.")] // below minimum
|
|
[InlineData("has a space in the middle!")] // whitespace
|
|
[InlineData("line\nbreak-0123456789")] // control char
|
|
[InlineData("umlauts-äöü-0123456789")] // non-ASCII
|
|
[InlineData("***REDACTED***")] // unrestored sentinel (too short anyway)
|
|
public void ValidateWebhookSigningSecret_RejectsInvalid(string secret)
|
|
{
|
|
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
|
|
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 129)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateNotificationChannels_RejectsBadPerChannelSigningSecret()
|
|
{
|
|
var json = "[" + ChannelJson("ch-1", extra: ",\"signingSecret\":\"too-short\"") + "]";
|
|
var error = TenantConfigValidation.ValidateNotificationChannels(json);
|
|
Assert.NotNull(error);
|
|
Assert.Contains("signing secret", error);
|
|
}
|
|
}
|