mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
d763a885e2
* feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad. Ein neuer Provider haette beide anfassen muessen. Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider — WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST. SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession, ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification). SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern. Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb ist das Gate eine Server-Regel, keine versteckte Dropdown-Option: ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann. Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft. Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList / RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin, statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson + GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload, NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0). Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar, damit ein Speichern ihn nicht still verwirft. Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub, der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden hiess also: das Sales-Team bekommt auch jede Security-Regel. Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und vereinigt ihre Ziele. Rueckwaertskompatibel ohne Migrationsjob: - Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet. - GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert. Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher wegdriftet. - Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel traegt, also verhaelt sich am Deploy-Tag nichts anders. - Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel, deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles. Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr geschrieben — aufraeumen erst, wenn produktiv Channels stehen. Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die auch die Tenant-Liste passiert. Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen. Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine Webhook-URL in den falschen Provider-Slot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend): TenantTrialStarted + Event-Payload optional pro Regel Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event. RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still, ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null) ist ein Downgrade und bleibt bei TenantPlanDowngraded. Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst: ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen, und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das Informationsniveau aller bestehenden Regeln bleibt damit unveraendert. ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln. Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte <= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein stabiler Schluessel zum Verzweigen. Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung, schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben ungetestet — Suite-Konvention ist, die reinen Kerne zu testen. Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md + docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events Query-Surface), und ein Teil-Commit darin haette es mitgenommen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies The ops-events read could only narrow by category and date window, so answering "did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and filtering it client-side — and for the MCP tool that client is a language model, so the question cost a category-sized response in model context. Ops-events filter surface (mirrors the audit-log filter pattern one-for-one): - OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters + pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters -> OData clauses). BOTH filter builders call the appender — the single-category / unpaged one and the per-partition fan-out builder that adds the RowKey bound — so "no category named" cannot silently return unfiltered rows. - minSeverity expands to an OR-set, not a range: Table Storage compares strings lexicographically and the severity names sort Critical < Error < Info < Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half. The ladder now lives in OpsEventSeverity.Rank, shared with OpsAlertDispatchService, so "Warning and above" in a read means what it meant when the alert rules fired. - severity is normalised onto the canonical vocabulary and an unknown value is a 400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an empty page that reads exactly like "no such events happened". eventType stays verbatim — stored rows outlive the vocabulary, so a retired type must remain searchable in the historical window. - Every filter value is folded into the continuation fingerprint and echoed on nextLink; extras are appended AFTER the pre-existing category/tenantId discriminators and skipped when empty, so tokens minted before the filters existed still fingerprint identically. The tenantId drill deliberately stays in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing). - MCP get_ops_events gains eventType / severity / minSeverity plus a `days` shorthand resolved client-side to a concrete dateFrom (a backend-side "last N days" would re-resolve "now" on every page and blow the token fingerprint); an explicit bound always wins over the shorthand. Fixed alongside: the paged cross-category fan-out skipped Platform (Azure Monitor alerts) because its category list had drifted from OpsEventCategory — written from day one, invisible to every paged reader, still visible on the unpaged full-table path. The list is now OpsEventCategory.All plus a reflection coverage test. That drift had a structural cause, so the same round closes it for the MCP as a whole: wire TYPES reached the MCP from day one, but vocabularies did not — types are erased at runtime and z.enum() needs values, so every tool schema hand-typed its own copy with nothing to compare against. The copies had drifted: search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete — both real stored statuses), both event readers offered 4 of 6 severities (no Debug, no Trace), get_ops_events named 6 of 7 ops categories. - The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES: session statuses, event severities, ops categories/severities/types, annotation lanes/verdicts) from the manifest sections listed in MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops vocabularies. Freshness is pinned twice — byte-equality against a fresh codegen run in the MCP suite, and the shared-manifests-in-sync CI job. - Tool schemas derive their z.enum() from it and descriptions interpolate the list instead of spelling it. - vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..]) must sit on a baseline naming why it has no backend owner (MCP-local knobs, plus rule type / connection type / CVE risk, which have no C# constants class yet). A new hand-typed list fails, and so does a stale baseline entry. - OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the OpsEventService write sites use the constants (a raw literal now fails OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection), the manifest exports them, and get_resource(name="ops_event_types") serves categories + severities + types to the model from the generated copy. Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent. Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an older backend gets silently unfiltered rows back and presents them as filtered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: register the ops notification channels doc in the index and log The concept doc landed with the feature commits; its index entry and log entry were still uncommitted in the working tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
226 lines
10 KiB
C#
226 lines
10 KiB
C#
using System.Net.Http;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Functions.Services.Notifications;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Models.Notifications;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Telegram as a notification-channel provider: dispatch routing (it must never reach the
|
|
/// SSRF-guarded webhook path), chat-ID validation, and the Global-Admin gate that keeps a
|
|
/// tenant admin from sending through the platform-owned bot.
|
|
/// </summary>
|
|
public class TelegramChannelProviderTests
|
|
{
|
|
private static Mock<WebhookNotificationService> WebhookMock()
|
|
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
|
|
|
|
private static Mock<TelegramNotificationService> TelegramMock()
|
|
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
|
{ CallBase = false };
|
|
|
|
private static NotificationChannel Channel(int providerType, string? url, string id = "c1", bool enabled = true)
|
|
=> new() { Id = id, Name = id, ProviderType = providerType, Url = url, Enabled = enabled };
|
|
|
|
private static string ChannelJson(int providerType, string url, string id = "c1", bool enabled = true)
|
|
=> $"[{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":{(enabled ? "true" : "false")}}}]";
|
|
|
|
// ── Dispatch routing ──────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook()
|
|
{
|
|
var webhook = WebhookMock();
|
|
var telegram = TelegramMock();
|
|
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
|
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
|
|
|
await dispatcher.SendToChannelsAsync(
|
|
new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert);
|
|
|
|
telegram.Verify(t => t.SendOpsAlertAsync("-1003785642894", alert), Times.Once);
|
|
webhook.Verify(w => w.SendNotificationAsync(
|
|
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
|
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendToChannels_RoutesWebhookProvidersToWebhookService()
|
|
{
|
|
var webhook = WebhookMock();
|
|
var telegram = TelegramMock();
|
|
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
|
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
|
|
|
await dispatcher.SendToChannelsAsync(
|
|
new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert);
|
|
|
|
webhook.Verify(w => w.SendNotificationAsync(
|
|
"https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
|
|
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
|
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport()
|
|
{
|
|
var webhook = WebhookMock();
|
|
var telegram = TelegramMock();
|
|
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
|
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
|
|
|
await dispatcher.SendToChannelsAsync(new[]
|
|
{
|
|
Channel((int)WebhookProviderType.Telegram, "-100123", "tg"),
|
|
Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"),
|
|
}, alert);
|
|
|
|
telegram.Verify(t => t.SendOpsAlertAsync("-100123", alert), Times.Once);
|
|
webhook.Verify(w => w.SendNotificationAsync(
|
|
"https://sales.example/hook", WebhookProviderType.GenericJson, alert,
|
|
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendToChannels_SkipsChannelsWithoutDestination()
|
|
{
|
|
var webhook = WebhookMock();
|
|
var telegram = TelegramMock();
|
|
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
|
|
|
await dispatcher.SendToChannelsAsync(new[]
|
|
{
|
|
Channel((int)WebhookProviderType.Telegram, null, "tg"),
|
|
Channel((int)WebhookProviderType.Slack, "", "slack"),
|
|
}, new NotificationAlert { Title = "t", Summary = "s" });
|
|
|
|
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
|
webhook.Verify(w => w.SendNotificationAsync(
|
|
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
|
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendWithResult_RoutesTelegramToBot()
|
|
{
|
|
var webhook = WebhookMock();
|
|
var telegram = TelegramMock();
|
|
telegram.Setup(t => t.SendAlertWithResultAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
|
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "ok" });
|
|
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
|
|
|
var result = await dispatcher.SendWithResultAsync(
|
|
Channel((int)WebhookProviderType.Telegram, "@salesdesk"),
|
|
new NotificationAlert { Title = "t", Summary = "s" });
|
|
|
|
Assert.True(result.Success);
|
|
telegram.Verify(t => t.SendAlertWithResultAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
|
|
}
|
|
|
|
// ── Chat-ID validation ────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("-1003785642894")]
|
|
[InlineData("123456789")]
|
|
[InlineData("@salesdesk")]
|
|
[InlineData("@Sales_Desk_01")]
|
|
public void ValidateTelegramChatId_AcceptsNumericAndUsernameForms(string chatId)
|
|
=> Assert.Null(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("https://api.telegram.org/bot123/sendMessage")]
|
|
[InlineData("-100abc")]
|
|
[InlineData("@abc")] // too short
|
|
[InlineData("@1sales")] // must start with a letter
|
|
[InlineData("@sales-desk")] // hyphen not allowed
|
|
[InlineData("123456789012345678901")] // 21 digits
|
|
public void ValidateTelegramChatId_RejectsEverythingElse(string? chatId)
|
|
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
|
|
|
[Fact]
|
|
public void ValidateNotificationChannels_TelegramChatIdBypassesSsrfUrlGate()
|
|
{
|
|
// A chat ID is not a URL — the webhook SSRF gate would reject it. Proves the
|
|
// provider-specific destination branch is wired, not just the format helper.
|
|
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
|
|
ChannelJson((int)WebhookProviderType.Telegram, "-1003785642894")));
|
|
|
|
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
|
ChannelJson((int)WebhookProviderType.Slack, "-1003785642894")));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateNotificationChannels_RejectsTelegramChannelWithUrlDestination()
|
|
=> Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
|
ChannelJson((int)WebhookProviderType.Telegram, "https://hooks.example/x")));
|
|
|
|
// ── Global-Admin gate ─────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void TelegramGate_GlobalAdminMayAddChannel()
|
|
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
|
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: true));
|
|
|
|
[Fact]
|
|
public void TelegramGate_TenantAdminMayNotAddChannel()
|
|
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
|
|
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: false));
|
|
|
|
[Fact]
|
|
public void TelegramGate_TenantAdminMayNotRetargetExistingChannel()
|
|
{
|
|
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
|
var retargeted = ChannelJson((int)WebhookProviderType.Telegram, "-100999");
|
|
|
|
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, existing, isGlobalAdmin: false));
|
|
}
|
|
|
|
[Fact]
|
|
public void TelegramGate_TenantAdminMayNotFlipEnabledState()
|
|
{
|
|
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: true);
|
|
var disabled = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: false);
|
|
|
|
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(disabled, existing, isGlobalAdmin: false));
|
|
}
|
|
|
|
[Fact]
|
|
public void TelegramGate_TenantAdminMaySaveWithUnchangedGaCreatedChannel()
|
|
{
|
|
// The gate must not turn a GA-created Telegram channel into a permanent save block
|
|
// for the tenant admin's unrelated configuration changes.
|
|
var json = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
|
|
|
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
|
|
}
|
|
|
|
[Fact]
|
|
public void TelegramGate_IgnoresNonTelegramChannels()
|
|
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
|
ChannelJson((int)WebhookProviderType.GenericJson, "https://sales.example/hook"), null, isGlobalAdmin: false));
|
|
|
|
// ── Both write paths (PUT + field patch) go through ValidateModel ─────
|
|
|
|
[Fact]
|
|
public void ValidateModel_EnforcesTelegramGateForNonGlobalAdmin()
|
|
{
|
|
var candidate = new TenantConfiguration
|
|
{
|
|
TenantId = "t1",
|
|
NotificationChannelsJson = ChannelJson((int)WebhookProviderType.Telegram, "-100123"),
|
|
};
|
|
var existing = new TenantConfiguration { TenantId = "t1" };
|
|
|
|
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
|
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
|
|
}
|
|
}
|