diff --git a/extension/README.md b/extension/README.md index c756f2b..d3d647d 100644 --- a/extension/README.md +++ b/extension/README.md @@ -203,7 +203,7 @@ and is not repeated here. ### 0. Before touching the console - [ ] The EBS is deployed and reachable at `https://ednexus.signal-and-thread.com` over valid TLS - (`GET /healthz` returns `{"status":"ok"}`), and the same origin is what the desktop app's + (`GET /healthz` returns `status: "ok"`, plus `pubsub: "unknown"`, `"accepted"` or `"rejected"`: a `rejected` means Twitch is refusing the EBS's credentials and viewers get no live updates, see the EBS README's credential self-check), and the same origin is what the desktop app's `AppSettings.Twitch.EbsBaseUrl` defaults to. - [ ] The desktop app build that publishes a **heartbeat** has shipped (see [Publishing contract](#publishing-contract-with-the-desktop-app)). Without it, an idle docked diff --git a/src/EDNexus.App/ViewModels/MainWindowViewModel.cs b/src/EDNexus.App/ViewModels/MainWindowViewModel.cs index 981518f..f89a4f3 100644 --- a/src/EDNexus.App/ViewModels/MainWindowViewModel.cs +++ b/src/EDNexus.App/ViewModels/MainWindowViewModel.cs @@ -265,6 +265,7 @@ private EngineHost BuildHost() { if (result.IsSuccess) Trace.TraceInformation("Twitch: stream card published."); else Trace.TraceWarning($"Twitch: stream card publish failed ({result.Status}) — {result.Error}"); + UpdateTwitchWarning(result); TwitchPublishCompleted?.Invoke(result); }; twitchCard.ReauthRequired += () => @@ -374,6 +375,24 @@ private void UpdateJournalStatus() private void OnDiscordSettingsChanged(DiscordSettings settings) => _host.ApplyDiscordSettings(settings); [ObservableProperty] private string _journalStatus = ""; + + /// + /// A warning, shown on the main window, that the stream card is failing for a reason the commander + /// cannot fix (Twitch refusing the backend's credentials). Empty when there is nothing to say. The + /// Settings window's status line is only seen with Settings open; this is seen while playing. + /// + [ObservableProperty, NotifyPropertyChangedFor(nameof(HasTwitchWarning))] private string _twitchWarning = ""; + + public bool HasTwitchWarning => TwitchWarning.Length > 0; + + /// Raises or clears from a publish result; runs on the UI thread, since results arrive on the publisher's. + private void UpdateTwitchWarning(EDNexus.Core.Twitch.StreamStatePublishResult result) + { + if (EDNexus.Core.Twitch.StreamCardWarning.For(result) is { } warning) + Dispatcher.UIThread.Post(() => TwitchWarning = warning); + else if (EDNexus.Core.Twitch.StreamCardWarning.Clears(result)) + Dispatcher.UIThread.Post(() => TwitchWarning = ""); + } [ObservableProperty] private string _privacyStatus = ""; [ObservableProperty] private string _commanderName = "—"; [ObservableProperty] private string _balance = "0 cr"; diff --git a/src/EDNexus.App/Views/MainWindow.axaml b/src/EDNexus.App/Views/MainWindow.axaml index e08347d..da31a0d 100644 --- a/src/EDNexus.App/Views/MainWindow.axaml +++ b/src/EDNexus.App/Views/MainWindow.axaml @@ -217,6 +217,14 @@ + + + + + diff --git a/src/EDNexus.App/Views/SettingsWindow.axaml.cs b/src/EDNexus.App/Views/SettingsWindow.axaml.cs index a3713f8..d5bb4fd 100644 --- a/src/EDNexus.App/Views/SettingsWindow.axaml.cs +++ b/src/EDNexus.App/Views/SettingsWindow.axaml.cs @@ -623,6 +623,8 @@ private void OnTwitchPublishCompleted(StreamStatePublishResult result) => StreamStatePublishStatus.Unauthorized => "The backend rejected this machine's credential — sign in again.", StreamStatePublishStatus.TooLarge => "The last card was too large for Twitch — try hiding a section.", StreamStatePublishStatus.RateLimited => "Publishing is being rate-limited; updates are slowing down.", + StreamStatePublishStatus.BackendRejectedByTwitch => + "Twitch is rejecting the backend's credentials, so viewers are not getting live updates. It is a backend problem, not your sign-in.", StreamStatePublishStatus.Cleared => "Card taken off the air.", StreamStatePublishStatus.ClearedNotDelivered => "Card taken off the air; viewers already watching will be told on the next retry.", diff --git a/src/EDNexus.Core/Twitch/StreamCardWarning.cs b/src/EDNexus.Core/Twitch/StreamCardWarning.cs new file mode 100644 index 0000000..12ae24e --- /dev/null +++ b/src/EDNexus.Core/Twitch/StreamCardWarning.cs @@ -0,0 +1,28 @@ +namespace EDNexus.Core.Twitch; + +/// +/// The warning the app shows when publishing the stream card is failing for a reason the commander +/// cannot fix from their side. Kept apart from the UI so the wording and the rule for when it appears +/// and clears are tested. +/// +public static class StreamCardWarning +{ + /// + /// What to show after a publish, or null for nothing. Only a + /// raises one: a plain failure (the network dropped, a rate limit) already has its own status line + /// and clears itself on the next success, whereas a credential the backend operator must fix would + /// otherwise go unnoticed, since the card keeps looking fine on the commander's own machine. + /// + public static string? For(StreamStatePublishResult result) => + result.Status == StreamStatePublishStatus.BackendRejectedByTwitch + ? "Twitch is rejecting the EDNexus backend's credentials, so viewers are not getting live updates. " + + "This is a problem with the backend's configuration, not with your sign-in; it keeps retrying and " + + "the card will start updating again as soon as it is fixed." + : null; + + /// + /// True when this result shows the problem is over: the card reached viewers (or was taken down + /// cleanly). A failure of some other kind proves nothing either way, so it leaves a shown warning alone. + /// + public static bool Clears(StreamStatePublishResult result) => result.IsSuccess; +} diff --git a/src/EDNexus.Core/Twitch/StreamStateApiClient.cs b/src/EDNexus.Core/Twitch/StreamStateApiClient.cs index 0ecafae..ca44ffa 100644 --- a/src/EDNexus.Core/Twitch/StreamStateApiClient.cs +++ b/src/EDNexus.Core/Twitch/StreamStateApiClient.cs @@ -34,6 +34,14 @@ public enum StreamStatePublishStatus /// already watching. Worth retrying for their sake; nothing is left public to anyone new. /// ClearedNotDelivered, + + /// + /// Twitch is refusing the backend's own PubSub credentials (503 with the + /// header). The card is stored, so new + /// viewers still get it, but nothing reaches viewers already watching. It is a server + /// misconfiguration: not the commander's sign-in (so no re-login) and not worth retrying hard. + /// + BackendRejectedByTwitch, } /// Whether the snapshot reached viewers, and if not, why not. @@ -103,6 +111,12 @@ public StreamStateApiClient(HttpClient? http = null) /// public const string SnapshotRemovedHeader = "X-EDNexus-Snapshot-Removed"; + /// + /// Header the EBS sets when Twitch is refusing the EBS's own PubSub credentials. Mirrors + /// EDNexus.Ebs.Services.TwitchCredentialStatus.RejectedHeader. + /// + public const string PubSubRejectedHeader = "X-EDNexus-Pubsub-Rejected"; + public Task PublishAsync( string updateStateEndpoint, string token, StreamCardSnapshot snapshot, CancellationToken ct = default) { @@ -156,6 +170,7 @@ private async Task SendAsync( var status = response.StatusCode switch { _ when response.Headers.Contains(SnapshotRemovedHeader) => StreamStatePublishStatus.ClearedNotDelivered, + _ when response.Headers.Contains(PubSubRejectedHeader) => StreamStatePublishStatus.BackendRejectedByTwitch, HttpStatusCode.Unauthorized => StreamStatePublishStatus.Unauthorized, HttpStatusCode.RequestEntityTooLarge => StreamStatePublishStatus.TooLarge, HttpStatusCode.TooManyRequests => StreamStatePublishStatus.RateLimited, diff --git a/src/EDNexus.Core/Twitch/TwitchStreamCardService.cs b/src/EDNexus.Core/Twitch/TwitchStreamCardService.cs index c41b1dc..9c48c0d 100644 --- a/src/EDNexus.Core/Twitch/TwitchStreamCardService.cs +++ b/src/EDNexus.Core/Twitch/TwitchStreamCardService.cs @@ -193,6 +193,15 @@ internal TwitchStreamCardService( // the EBS's initial state. The host calls RequestPublish() once the replay is done. } + /// + /// How long to wait between retries while the backend's Twitch credentials are being refused: + /// 24 times the publish floor, capped at (so two minutes at the default + /// five-second floor). Scaled from the floor rather than fixed so a test with a tiny floor does not + /// have to wait minutes. + /// + internal TimeSpan BackendFaultRetryInterval => + TimeSpan.FromTicks(Math.Min(MaxRetryAfter.Ticks, _minInterval.Ticks * 24)); + /// The floor between two publishes this service was built with. public TimeSpan MinInterval => _minInterval; @@ -290,7 +299,8 @@ private async Task PumpAsync(CancellationToken ct) try { var published = await PublishIfChangedAsync(ct).ConfigureAwait(false); - if (published is { Status: StreamStatePublishStatus.RateLimited or StreamStatePublishStatus.Failed }) + if (published is { Status: StreamStatePublishStatus.RateLimited or StreamStatePublishStatus.Failed + or StreamStatePublishStatus.BackendRejectedByTwitch }) { // Give a struggling EBS (or a tighter-than-expected rate limit) room to recover // rather than retrying at the floor interval. @@ -299,7 +309,21 @@ private async Task PumpAsync(CancellationToken ct) // Capped, so a misconfigured proxy's header cannot silence the card for the session. if (published.RetryAfter is { } wait && wait > backoff) backoff = wait < MaxRetryAfter ? wait : MaxRetryAfter; - retry = ++_consecutiveFailures <= MaxPublishRetries; + if (published.Status == StreamStatePublishStatus.BackendRejectedByTwitch) + { + // A backend fault lasts as long as it takes an operator to fix a secret: minutes or + // hours, not the seconds a bounded retry covers. The warning on screen promises it keeps + // retrying, and with no earlier success even the heartbeat wake is skipped, so giving up + // would leave an idle commander's card stale until something changed. So retry for as long + // as it takes, slowly (one small request every BackendFaultRetryInterval), and outside + // the bounded budget that stops a dead EBS from being polled forever. + if (backoff < BackendFaultRetryInterval) backoff = BackendFaultRetryInterval; + retry = true; + } + else + { + retry = ++_consecutiveFailures <= MaxPublishRetries; + } } else if (published is not null) { diff --git a/src/EDNexus.Ebs/Program.cs b/src/EDNexus.Ebs/Program.cs index 37fe155..279784e 100644 --- a/src/EDNexus.Ebs/Program.cs +++ b/src/EDNexus.Ebs/Program.cs @@ -122,6 +122,14 @@ // long a single misbehaving/slow call can hold resources. client.Timeout = TimeSpan.FromSeconds(10); }); + // Whether Twitch accepts the credentials PubSub calls are signed with: written by every real + // broadcast and by the startup check, read by /healthz and by the update-state response. + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(10); + }); + builder.Services.AddHostedService(); builder.Services.AddHttpClient(client => { // Same reasoning as above, and a login/refresh waits on this: fail in seconds, not the 100s default. @@ -306,7 +314,20 @@ await Results.Problem( // project site rather than a 404. app.MapGet("/", () => Results.Redirect("https://signal-thread.github.io/EDNexus/")); - app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); + // "ok" means the process is up and answering, which is all a container health check should + // gate on, so a credential problem is reported alongside it, never instead of it: a wrong secret + // should not get the container restarted in a loop. `pubsub` is "unknown", "accepted" or "rejected". + app.MapGet("/healthz", (TwitchCredentialStatus credentials) => + { + var (state, httpStatus, at) = credentials.Current; + return Results.Ok(new + { + status = "ok", + pubsub = state.ToString().ToLowerInvariant(), + pubsubStatus = httpStatus, + pubsubCheckedAt = at, + }); + }); app.MapOAuthEndpoints(); @@ -349,7 +370,7 @@ await Results.Problem( // not the request's original whitespace, which the limit never saw. using var compactState = JsonDocument.Parse(pubSubRequest.Message); - bool published; + PubSubBroadcastResult broadcast; using (await channelLocks.AcquireAsync(channelId, cancellationToken).ConfigureAwait(false)) { // Authenticated again now that this channel is ours alone: a sign-out (POST /oauth/revoke) that @@ -362,12 +383,27 @@ await Results.Problem( } stateStore.Set(channelId, compactState.RootElement); - published = await pubSubClient.BroadcastAsync(channelId, compactState.RootElement, cancellationToken).ConfigureAwait(false); + broadcast = await pubSubClient.BroadcastAsync(channelId, compactState.RootElement, cancellationToken).ConfigureAwait(false); } - if (!published) + if (broadcast != PubSubBroadcastResult.Published) { logger.LogWarning("Failed to publish state update for channel {ChannelId} to Twitch PubSub.", channelId); + + // Twitch refusing the EBS's own credentials is a server misconfiguration, not an outage, + // and no amount of retrying fixes it. Say so with a header (a proxy in front can replace + // a 502's body) and a 503 (which one does not rewrite), so the desktop app can tell the + // commander it is not their sign-in, instead of a bare "502". Decided from THIS call's + // outcome, not the shared status: that stays "rejected" after an earlier refusal, and a + // later 400 or Twitch outage must not be reported as a credentials problem. + if (broadcast == PubSubBroadcastResult.CredentialsRejected) + { + httpRequest.HttpContext.Response.Headers[TwitchCredentialStatus.RejectedHeader] = "credentials"; + return Results.Problem( + "Twitch is rejecting this service's PubSub credentials, so live updates cannot be delivered. The card is stored and new viewers still see it. This is a server configuration problem.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + return Results.Problem("Failed to publish the update to Twitch PubSub.", statusCode: StatusCodes.Status502BadGateway); } diff --git a/src/EDNexus.Ebs/README.md b/src/EDNexus.Ebs/README.md index 70144e2..51936cb 100644 --- a/src/EDNexus.Ebs/README.md +++ b/src/EDNexus.Ebs/README.md @@ -111,6 +111,47 @@ end to end instead of finding out when a viewer reports a blank card: 5. Switch the card off (`DELETE /api/update-state`, or the app's toggle) and confirm the card disappears and `initial-state` answers `404`. +### Credential self-check + +A wrong `Twitch__ExtensionSecret` used to be silent: the service started, stored every card, answered +`/healthz` with `ok`, and never delivered a live update. Viewers who load the channel still saw the +card (it is served from the store), so nothing looked broken until someone noticed a card that never +changed. Three things now make it loud: + +1. **A startup check.** About 10 seconds after start, then every 6 hours, the EBS sends the real + Helix PubSub endpoint a request signed exactly like a broadcast but aimed at a target Twitch cannot + deliver to, so **nothing is ever sent to a viewer**. Twitch refusing the signed token + (`401`/`403`) logs at **error**: `Twitch REJECTED the EBS's PubSub credentials (403: ...)`, naming + the three settings to check. A `400` (the credentials got through and only the bogus target was + refused) logs `Twitch did not refuse the EBS's PubSub credentials`. Anything else (a network error, + a 5xx, a 429) is "inconclusive" and changes nothing. It never throws or stops the service. +2. **Real broadcasts keep the record.** Every actual PubSub call updates the same status: a refusal + sets it, a success clears it. This is the authority, so a stale startup verdict cannot outlive the + first real publish. +3. **`/healthz` reports it, and publishes carry it.** `/healthz` still answers `status: "ok"` (a wrong + secret must not get the container restarted in a loop, which would not fix it) with + `pubsub: "unknown" | "accepted" | "rejected"`, plus `pubsubStatus` (the HTTP status behind a + rejection) and `pubsubCheckedAt`. A `POST /api/update-state` that fails *because* Twitch is + refusing these credentials returns **`503`** with the header `X-EDNexus-Pubsub-Rejected: credentials` + instead of a bare `502` (a proxy in front can replace a 502's body, so the header and the status + code carry the meaning). The desktop app turns that into a warning on its main window. + +```bash +curl -s https:///healthz +# {"status":"ok","pubsub":"rejected","pubsubStatus":403,"pubsubCheckedAt":"2026-10-18T17:50:43+00:00"} +``` + +What `rejected` means and how to fix it: `Twitch__ExtensionSecret` must be this extension's own base64 +secret from the Developer Console (not another extension's, a rotated one, or the application's Client +Secret); `Twitch__ExtensionId` must be this extension's Client ID; and set `Twitch__OwnerUserId` to the +numeric Twitch user id of the extension's owner. Restart the service and watch `/healthz` (it updates +on the next real publish; the check also re-runs on its 6 hour cycle). + +> **What the check does not prove.** Twitch verifying the token before it looks at the request body is +> reasoned from its documentation, not observed: the refusal side was seen in production (`403 JWT could +> not be verified`), the acceptance side was not. So `accepted` from the startup check means "not +> refused"; only a real broadcast proves the credentials work, and that is why it overrides the check. + ## Behind a reverse proxy The container speaks plain HTTP and expects a TLS-terminating proxy (Caddy in the compose file) in @@ -203,7 +244,10 @@ Called by the desktop client on behalf of the broadcaster. - **Responses**: `200 OK` on success, `401 Unauthorized` for a missing/invalid/revoked token, `400 Bad Request` for a missing/`null`/non-object `state`, `413 Payload Too Large` if the state or the request body exceeds its limit, `429 Too Many Requests` if - the per-channel rate limit is exceeded, `502 Bad Gateway` if Twitch PubSub rejects the message. + the per-channel rate limit is exceeded, `502 Bad Gateway` if Twitch PubSub fails the message, and + `503 Service Unavailable` with `X-EDNexus-Pubsub-Rejected: credentials` when the failure is Twitch + refusing **this service's own credentials** (see [Credential self-check](#credential-self-check)). + The state is stored in every one of these cases, so new viewers still get the card. ### `DELETE /api/update-state` diff --git a/src/EDNexus.Ebs/Services/ChannelStateClearing.cs b/src/EDNexus.Ebs/Services/ChannelStateClearing.cs index a0a556e..ba4cc50 100644 --- a/src/EDNexus.Ebs/Services/ChannelStateClearing.cs +++ b/src/EDNexus.Ebs/Services/ChannelStateClearing.cs @@ -33,7 +33,7 @@ public static class ChannelStateClearing public static async Task ClearAsync(string channelId, IChannelStateStore stateStore, ITwitchPubSubClient pubSubClient, CancellationToken ct) { stateStore.Remove(channelId); - try { return await pubSubClient.BroadcastAsync(channelId, OfflineMessage, ct).ConfigureAwait(false); } + try { return await pubSubClient.BroadcastAsync(channelId, OfflineMessage, ct).ConfigureAwait(false) == PubSubBroadcastResult.Published; } catch (Exception ex) when (ex is not OperationCanceledException) { return false; } } } diff --git a/src/EDNexus.Ebs/Services/ITwitchPubSubClient.cs b/src/EDNexus.Ebs/Services/ITwitchPubSubClient.cs index a9adfa7..3874b0a 100644 --- a/src/EDNexus.Ebs/Services/ITwitchPubSubClient.cs +++ b/src/EDNexus.Ebs/Services/ITwitchPubSubClient.cs @@ -2,6 +2,23 @@ namespace EDNexus.Ebs.Services; +/// How a single PubSub broadcast ended. +public enum PubSubBroadcastResult +{ + /// Twitch accepted the message (HTTP 204). + Published, + + /// + /// Twitch refused the credentials the call was signed with (401/403): a server + /// misconfiguration that no retry fixes. Reported per call so a caller never has to infer it from + /// shared state, which can be left over from an earlier call. + /// + CredentialsRejected, + + /// Anything else: a bad payload, a Twitch outage, a rate limit. + Failed, +} + /// Broadcasts state payloads to viewers of a channel via Twitch's Extensions PubSub API. public interface ITwitchPubSubClient { @@ -9,6 +26,6 @@ public interface ITwitchPubSubClient /// Sends to every viewer of currently /// listening on the extension's "broadcast" PubSub target. /// - /// True when Twitch accepted the message (HTTP 204). - Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken); + /// How this call ended; when Twitch accepted the message (HTTP 204). + Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken); } diff --git a/src/EDNexus.Ebs/Services/TwitchCredentialCheckBackgroundService.cs b/src/EDNexus.Ebs/Services/TwitchCredentialCheckBackgroundService.cs new file mode 100644 index 0000000..aa5d3d0 --- /dev/null +++ b/src/EDNexus.Ebs/Services/TwitchCredentialCheckBackgroundService.cs @@ -0,0 +1,73 @@ +namespace EDNexus.Ebs.Services; + +/// +/// Checks, shortly after startup and then every few hours, that Twitch accepts the credentials the +/// EBS signs PubSub calls with, and says so loudly in the log when it does not. +/// +/// +/// Without this a wrong Twitch__ExtensionSecret is silent: the service starts, stores every +/// card, answers /healthz with ok, and simply never delivers a live update, which nobody +/// notices until a viewer's card stops changing. It never throws or stops the host, and it only +/// reports: the real broadcasts keep the authoritative record (see ). +/// +public sealed class TwitchCredentialCheckBackgroundService : BackgroundService +{ + /// Delay before the first check, so the host is listening and a restart loop does not spam Twitch. + public static readonly TimeSpan InitialDelay = TimeSpan.FromSeconds(10); + + /// Between checks once running: often enough to notice a rotated secret, rare enough to be no load. + public static readonly TimeSpan Interval = TimeSpan.FromHours(6); + + private readonly ITwitchCredentialProbe _probe; + private readonly IHostEnvironment _environment; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public TwitchCredentialCheckBackgroundService( + ITwitchCredentialProbe probe, + IHostEnvironment environment, + TimeProvider time, + ILogger logger) + { + _probe = probe; + _environment = environment; + _time = time; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Decided here, not when the service is registered: the test host applies its environment late, + // and a check that reached out to Twitch from every long-running test would be a flaky one. + if (_environment.IsEnvironment("Testing")) return; + + try + { + await Task.Delay(InitialDelay, _time, stoppingToken).ConfigureAwait(false); + + while (!stoppingToken.IsCancellationRequested) + { + await RunOnceAsync(stoppingToken).ConfigureAwait(false); + await Task.Delay(Interval, _time, stoppingToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } + } + + /// One check, exposed so tests can drive it without waiting on the delays. + internal async Task RunOnceAsync(CancellationToken ct) + { + try + { + await _probe.CheckAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The probe already contains its own failures; this is the last line of defence for the host. + _logger.LogError(ex, "Unhandled error in the Twitch credential check."); + } + } +} diff --git a/src/EDNexus.Ebs/Services/TwitchCredentialProbe.cs b/src/EDNexus.Ebs/Services/TwitchCredentialProbe.cs new file mode 100644 index 0000000..5e66742 --- /dev/null +++ b/src/EDNexus.Ebs/Services/TwitchCredentialProbe.cs @@ -0,0 +1,143 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using EDNexus.Ebs.Options; +using EDNexus.Ebs.Security; +using Microsoft.Extensions.Options; + +namespace EDNexus.Ebs.Services; + +/// What a credential check found. +public enum TwitchCredentialProbeResult +{ + /// The check could not tell (a network error, or a response that says nothing about the credentials). + Inconclusive = 0, + + /// Twitch did not refuse the credentials. + Accepted, + + /// Twitch refused the credentials. + Rejected, +} + +/// Asks Twitch, without broadcasting anything, whether it accepts the credentials PubSub calls are signed with. +public interface ITwitchCredentialProbe +{ + /// Runs one check and records a definite answer in . + Task CheckAsync(CancellationToken cancellationToken); +} + +/// +/// Checks the PubSub credentials by sending the real endpoint a request that is signed exactly like a +/// real broadcast but carries a deliberately invalid target, so it can never reach a viewer. +/// +/// +/// Twitch verifies the signed token before it looks at the body, so a refused credential comes back +/// 401/403 and an accepted one comes back 400 (the bogus target is what it then +/// rejects). Going through the same endpoint, Client-Id and token claims as a real publish +/// is the point: a different endpoint would be checking a different set of rules. +/// +/// Two limits, stated rather than hidden. The refusal side is the one seen in production +/// (403 JWT could not be verified); that Twitch really does check the token before the body is +/// reasoned from its documentation, not observed, so an +/// here means "not refused", and only a real broadcast proves the credentials work. And this never +/// guesses: anything it does not recognise is , +/// which changes nothing. +/// +/// +public sealed class TwitchCredentialProbe : ITwitchCredentialProbe +{ + private const string PubSubUrl = "https://api.twitch.tv/helix/extensions/pubsub"; + + /// Not a target Twitch knows (broadcast, global, whisper-…), so it is refused whatever else is true. + internal const string InvalidTarget = "__ednexus_credential_check__"; + + private readonly HttpClient _httpClient; + private readonly ITwitchExtensionJwtService _jwtService; + private readonly TwitchEbsOptions _options; + private readonly TwitchCredentialStatus _status; + private readonly ILogger _logger; + + public TwitchCredentialProbe( + HttpClient httpClient, + ITwitchExtensionJwtService jwtService, + IOptions options, + TwitchCredentialStatus status, + ILogger logger) + { + _httpClient = httpClient; + _jwtService = jwtService; + _options = options.Value; + _status = status; + _logger = logger; + } + + /// + public async Task CheckAsync(CancellationToken cancellationToken) + { + // Any channel id will do for a request that is going to be refused on its target; the owner's + // is the most natural, and "0" cannot be a real one. + var channelId = !string.IsNullOrWhiteSpace(_options.OwnerUserId) ? _options.OwnerUserId.Trim() : "0"; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, PubSubUrl) + { + Content = JsonContent.Create(new + { + broadcaster_id = channelId, + target = new[] { InvalidTarget }, + message = "{}", + }), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _jwtService.CreateExternalServiceToken(channelId)); + request.Headers.Add("Client-Id", TwitchPubSubClient.ClientIdHeader(_options)); + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return await ClassifyAsync(response, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // A check that cannot reach Twitch (or cannot even sign: a malformed secret) says nothing + // about whether Twitch accepts the credentials, and must never take the service down. + _logger.LogWarning(ex, "The Twitch credential check could not be completed; it proves nothing either way."); + return TwitchCredentialProbeResult.Inconclusive; + } + } + + private async Task ClassifyAsync(HttpResponseMessage response, CancellationToken ct) + { + var status = (int)response.StatusCode; + + if (TwitchCredentialStatus.IsCredentialRefusal(status)) + { + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + _status.MarkRejected(status); + _logger.LogError( + "Twitch REJECTED the EBS's PubSub credentials ({StatusCode}: {Body}). Live updates will NOT reach viewers " + + "until this is fixed. Check Twitch__ExtensionSecret (this extension's own base64 secret), Twitch__ExtensionId " + + "(this extension's Client ID) and Twitch__OwnerUserId (the numeric Twitch user id of the extension's owner).", + status, body); + return TwitchCredentialProbeResult.Rejected; + } + + // 400: the credentials got past verification and only the deliberately invalid target was + // refused, which is the expected answer. 2xx would mean Twitch accepted a message it should not + // have; the credentials are plainly not refused, but it is worth saying. + if (status == 400 || response.IsSuccessStatusCode) + { + _status.MarkAccepted(); + if (response.IsSuccessStatusCode) + _logger.LogWarning("The Twitch credential check was accepted outright ({StatusCode}); it should have been refused for its invalid target.", status); + else + _logger.LogInformation("Twitch did not refuse the EBS's PubSub credentials (the check's invalid target was refused, as intended)."); + return TwitchCredentialProbeResult.Accepted; + } + + _logger.LogWarning("The Twitch credential check got {StatusCode}, which says nothing about the credentials; leaving the status unchanged.", status); + return TwitchCredentialProbeResult.Inconclusive; + } +} diff --git a/src/EDNexus.Ebs/Services/TwitchCredentialStatus.cs b/src/EDNexus.Ebs/Services/TwitchCredentialStatus.cs new file mode 100644 index 0000000..99c9055 --- /dev/null +++ b/src/EDNexus.Ebs/Services/TwitchCredentialStatus.cs @@ -0,0 +1,80 @@ +namespace EDNexus.Ebs.Services; + +/// What the EBS last learned about whether Twitch accepts the credentials it signs PubSub calls with. +public enum TwitchCredentialState +{ + /// Nothing learned yet: the startup check has not run or could not tell. + Unknown = 0, + + /// Twitch accepted the credentials (a real broadcast succeeded, or the startup check was not refused). + Accepted, + + /// + /// Twitch refused the credentials (401/403, typically "JWT could not be verified"): + /// a wrong Twitch__ExtensionSecret, a Twitch__ExtensionId that is not the extension's, + /// or a wrong Twitch__OwnerUserId. Every broadcast fails until it is fixed, so viewers + /// already watching never receive a live update, though new viewers still get the stored card. + /// + Rejected, +} + +/// +/// The EBS's running answer to "does Twitch accept our PubSub credentials?", shared by the startup +/// check, every real broadcast, /healthz and the /api/update-state response. +/// +/// +/// A real broadcast is the authority: it uses exactly the credentials that matter, so a success +/// clears a rejection recorded by the startup check and a refusal sets one. Thread-safe: it is +/// written from request threads and the background check and read from both. +/// +public sealed class TwitchCredentialStatus +{ + /// + /// Response header on a /api/update-state failure caused by Twitch refusing the EBS's + /// credentials. A header, because a proxy in front of the EBS can replace the body of a 502; the + /// desktop app reads it to tell a server misconfiguration from a transient outage. Mirrored by + /// EDNexus.Core.Twitch.StreamStateApiClient.PubSubRejectedHeader. + /// + public const string RejectedHeader = "X-EDNexus-Pubsub-Rejected"; + + private readonly Lock _gate = new(); + private readonly TimeProvider _time; + private TwitchCredentialState _state; + private int? _httpStatus; + private DateTimeOffset? _at; + + public TwitchCredentialStatus(TimeProvider? time = null) => _time = time ?? TimeProvider.System; + + /// A consistent snapshot of the state, the HTTP status behind it, and when it was recorded. + public (TwitchCredentialState State, int? HttpStatus, DateTimeOffset? At) Current + { + get { lock (_gate) return (_state, _httpStatus, _at); } + } + + public TwitchCredentialState State => Current.State; + + /// Records that Twitch accepted the credentials. + public void MarkAccepted() + { + lock (_gate) + { + _state = TwitchCredentialState.Accepted; + _httpStatus = null; + _at = _time.GetUtcNow(); + } + } + + /// Records that Twitch refused the credentials with (401 or 403). + public void MarkRejected(int httpStatus) + { + lock (_gate) + { + _state = TwitchCredentialState.Rejected; + _httpStatus = httpStatus; + _at = _time.GetUtcNow(); + } + } + + /// True for the statuses Twitch uses to refuse a signed token: the credentials, not the payload or Twitch itself. + public static bool IsCredentialRefusal(int httpStatus) => httpStatus is 401 or 403; +} diff --git a/src/EDNexus.Ebs/Services/TwitchPubSubClient.cs b/src/EDNexus.Ebs/Services/TwitchPubSubClient.cs index 07d0e8b..00e3882 100644 --- a/src/EDNexus.Ebs/Services/TwitchPubSubClient.cs +++ b/src/EDNexus.Ebs/Services/TwitchPubSubClient.cs @@ -20,6 +20,7 @@ public sealed class TwitchPubSubClient : ITwitchPubSubClient private readonly ITwitchExtensionJwtService _jwtService; private readonly TwitchEbsOptions _twitchOptions; private readonly EbsOptions _ebsOptions; + private readonly TwitchCredentialStatus _credentials; private readonly ILogger _logger; public TwitchPubSubClient( @@ -27,12 +28,14 @@ public TwitchPubSubClient( ITwitchExtensionJwtService jwtService, IOptions twitchOptions, IOptions ebsOptions, + TwitchCredentialStatus credentials, ILogger logger) { _httpClient = httpClient; _jwtService = jwtService; _twitchOptions = twitchOptions.Value; _ebsOptions = ebsOptions.Value; + _credentials = credentials; _logger = logger; } @@ -41,7 +44,7 @@ internal static string ClientIdHeader(TwitchEbsOptions options) => string.IsNullOrWhiteSpace(options.ExtensionId) ? options.ClientId : options.ExtensionId; /// - public async Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken) + public async Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken) { var request = PubSubBroadcastRequest.Create(broadcasterId, state, _ebsOptions.MaxStatePayloadBytes); var token = _jwtService.CreateExternalServiceToken(broadcasterId); @@ -65,9 +68,19 @@ public async Task BroadcastAsync(string broadcasterId, JsonElement state, broadcasterId, (int)response.StatusCode, body); - return false; + // Only a refusal of the signed token says anything about the credentials; a 400 is the + // payload and a 5xx is Twitch, and neither should flip the state. + if (TwitchCredentialStatus.IsCredentialRefusal((int)response.StatusCode)) + { + _credentials.MarkRejected((int)response.StatusCode); + return PubSubBroadcastResult.CredentialsRejected; + } + + return PubSubBroadcastResult.Failed; } - return true; + // A real broadcast is the authority: it clears a rejection the startup check recorded. + _credentials.MarkAccepted(); + return PubSubBroadcastResult.Published; } } diff --git a/tests/EDNexus.Ebs.Tests/EbsHardeningTests.cs b/tests/EDNexus.Ebs.Tests/EbsHardeningTests.cs index 5404e3b..7a694c9 100644 --- a/tests/EDNexus.Ebs.Tests/EbsHardeningTests.cs +++ b/tests/EDNexus.Ebs.Tests/EbsHardeningTests.cs @@ -474,7 +474,7 @@ private sealed class GatedOfflinePubSubClient(string gatedChannel) : ITwitchPubS public ManualResetEventSlim OfflineEntered { get; } = new(); public TaskCompletionSource ReleaseOffline { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public async Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken) + public async Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken) { if (broadcasterId == gatedChannel && state.TryGetProperty("offline", out _)) { @@ -482,7 +482,7 @@ public async Task BroadcastAsync(string broadcasterId, JsonElement state, await ReleaseOffline.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken); } - return true; + return PubSubBroadcastResult.Published; } } } diff --git a/tests/EDNexus.Ebs.Tests/EbsUnitTests.cs b/tests/EDNexus.Ebs.Tests/EbsUnitTests.cs index aa8d6e0..7b80095 100644 --- a/tests/EDNexus.Ebs.Tests/EbsUnitTests.cs +++ b/tests/EDNexus.Ebs.Tests/EbsUnitTests.cs @@ -126,6 +126,7 @@ public class TwitchPubSubClientHeaderTests new TwitchExtensionJwtService(Microsoft.Extensions.Options.Options.Create(twitch)), Microsoft.Extensions.Options.Options.Create(twitch), Microsoft.Extensions.Options.Options.Create(new EbsOptions()), + new TwitchCredentialStatus(), NullLogger.Instance); private const string Secret = "c3VwZXItc2VjcmV0LWV4dGVuc2lvbi1rZXktMTIzNA=="; @@ -136,7 +137,7 @@ public async Task The_Client_Id_header_is_the_extensions_id_not_the_oauth_applic var handler = new RecordingHandler(HttpStatusCode.NoContent); var client = Create(handler, new TwitchEbsOptions { ClientId = "oauth-app", ExtensionId = "the-extension", ExtensionSecret = Secret }); - Assert.True(await client.BroadcastAsync("123", JsonSerializer.SerializeToElement(new { v = 1 }), CancellationToken.None)); + Assert.Equal(PubSubBroadcastResult.Published, await client.BroadcastAsync("123", JsonSerializer.SerializeToElement(new { v = 1 }), CancellationToken.None)); Assert.Equal("the-extension", Assert.Single(handler.ClientIds)); } diff --git a/tests/EDNexus.Ebs.Tests/OAuthEndpointsTests.cs b/tests/EDNexus.Ebs.Tests/OAuthEndpointsTests.cs index e425841..17bf10d 100644 --- a/tests/EDNexus.Ebs.Tests/OAuthEndpointsTests.cs +++ b/tests/EDNexus.Ebs.Tests/OAuthEndpointsTests.cs @@ -65,7 +65,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) /// Fake PubSub relay — the OAuth flow tests only care that authentication succeeded, not that Twitch PubSub itself is reachable. public sealed class FakePubSubClient : ITwitchPubSubClient { - public Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken) => Task.FromResult(true); + public Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken) => Task.FromResult(PubSubBroadcastResult.Published); } public sealed class FakeTwitchOAuthClient : ITwitchOAuthClient diff --git a/tests/EDNexus.Ebs.Tests/TwitchCredentialCheckTests.cs b/tests/EDNexus.Ebs.Tests/TwitchCredentialCheckTests.cs new file mode 100644 index 0000000..559b48e --- /dev/null +++ b/tests/EDNexus.Ebs.Tests/TwitchCredentialCheckTests.cs @@ -0,0 +1,376 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using EDNexus.Ebs.Options; +using EDNexus.Ebs.Security; +using EDNexus.Ebs.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace EDNexus.Ebs.Tests; + +/// +/// The EBS's answer to "does Twitch accept our PubSub credentials?": the startup check, the status +/// every real broadcast keeps, /healthz, and what a commander's publish gets back. A wrong +/// Twitch__ExtensionSecret used to be completely silent (the card was stored, /healthz said ok, +/// and nothing was ever delivered), which is exactly what these make loud. +/// +public class TwitchCredentialCheckTests +{ + private static TwitchEbsOptions Options(string? secret = EbsHostFactory.ExtensionSecret, string? owner = null) => new() + { + ClientId = "test-client-id", + ExtensionId = "test-extension-id", + ExtensionSecret = secret ?? "", + OwnerUserId = owner, + }; + + private static ITwitchExtensionJwtService Jwt(TwitchEbsOptions options) => new TwitchExtensionJwtService(Microsoft.Extensions.Options.Options.Create(options)); + + private static (TwitchCredentialProbe Probe, TwitchCredentialStatus Status, RecordingHandler Handler) Probe( + HttpStatusCode code, string body = "{}", string? secret = EbsHostFactory.ExtensionSecret, string? owner = null) + { + var options = Options(secret, owner); + var status = new TwitchCredentialStatus(); + var handler = new RecordingHandler(code, body); + var probe = new TwitchCredentialProbe(new HttpClient(handler), Jwt(options), Microsoft.Extensions.Options.Options.Create(options), status, NullLogger.Instance); + return (probe, status, handler); + } + + private static (TwitchPubSubClient Client, TwitchCredentialStatus Status) PubSub(HttpStatusCode code, TwitchCredentialStatus? status = null) + { + var options = Options(); + status ??= new TwitchCredentialStatus(); + var client = new TwitchPubSubClient( + new HttpClient(new RecordingHandler(code, "{}")), Jwt(options), + Microsoft.Extensions.Options.Options.Create(options), Microsoft.Extensions.Options.Options.Create(new EbsOptions()), + status, NullLogger.Instance); + return (client, status); + } + + private static JsonElement State() => JsonDocument.Parse("""{"v":1,"headline":"Docked"}""").RootElement.Clone(); + + // --- the startup check's verdict ----------------------------------------------------------------- + + [Theory] + [InlineData(HttpStatusCode.Forbidden)] // the one seen in production: "JWT could not be verified" + [InlineData(HttpStatusCode.Unauthorized)] + public async Task A_refused_token_is_a_rejection_and_is_recorded(HttpStatusCode code) + { + var (probe, status, _) = Probe(code, """{"error":"Forbidden","status":403,"message":"Error (403): JWT could not be verified"}"""); + + var result = await probe.CheckAsync(CancellationToken.None); + + Assert.Equal(TwitchCredentialProbeResult.Rejected, result); + Assert.Equal(TwitchCredentialState.Rejected, status.State); + Assert.Equal((int)code, status.Current.HttpStatus); + } + + [Fact] + public async Task A_400_means_the_credentials_got_through_and_only_the_bogus_target_was_refused() + { + var (probe, status, _) = Probe(HttpStatusCode.BadRequest, """{"error":"Bad Request","message":"Invalid target"}"""); + + Assert.Equal(TwitchCredentialProbeResult.Accepted, await probe.CheckAsync(CancellationToken.None)); + Assert.Equal(TwitchCredentialState.Accepted, status.State); + } + + [Fact] + public async Task An_outright_success_is_still_not_a_refusal() + { + var (probe, status, _) = Probe(HttpStatusCode.NoContent); + + Assert.Equal(TwitchCredentialProbeResult.Accepted, await probe.CheckAsync(CancellationToken.None)); + Assert.Equal(TwitchCredentialState.Accepted, status.State); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.NotFound)] + public async Task A_response_that_says_nothing_about_the_credentials_changes_nothing(HttpStatusCode code) + { + var (probe, status, _) = Probe(code); + + Assert.Equal(TwitchCredentialProbeResult.Inconclusive, await probe.CheckAsync(CancellationToken.None)); + Assert.Equal(TwitchCredentialState.Unknown, status.State); + } + + [Fact] + public async Task An_unreachable_Twitch_is_inconclusive_and_never_throws() + { + var options = Options(); + var status = new TwitchCredentialStatus(); + var probe = new TwitchCredentialProbe(new HttpClient(new ThrowingHandler()), Jwt(options), Microsoft.Extensions.Options.Options.Create(options), status, NullLogger.Instance); + + Assert.Equal(TwitchCredentialProbeResult.Inconclusive, await probe.CheckAsync(CancellationToken.None)); + Assert.Equal(TwitchCredentialState.Unknown, status.State); + } + + [Fact] + public async Task A_secret_that_is_not_even_base64_is_inconclusive_and_never_throws() + { + var (probe, status, handler) = Probe(HttpStatusCode.Forbidden, secret: "this is not base64!"); + + Assert.Equal(TwitchCredentialProbeResult.Inconclusive, await probe.CheckAsync(CancellationToken.None)); + Assert.Equal(TwitchCredentialState.Unknown, status.State); + Assert.Equal(0, handler.CallCount); // it could not sign, so it sent nothing + } + + [Fact] + public async Task The_check_cannot_reach_a_viewer_and_is_signed_like_a_real_broadcast() + { + var (probe, _, handler) = Probe(HttpStatusCode.BadRequest, owner: "424242"); + + await probe.CheckAsync(CancellationToken.None); + + var sent = JsonDocument.Parse(Assert.Single(handler.Bodies)).RootElement; + // The point of the design: a target Twitch cannot deliver to, so nothing is ever broadcast. + Assert.Equal(TwitchCredentialProbe.InvalidTarget, Assert.Single(sent.GetProperty("target").EnumerateArray()).GetString()); + Assert.DoesNotContain("\"broadcast\"", sent.GetProperty("target").GetRawText()); + Assert.Equal("424242", sent.GetProperty("broadcaster_id").GetString()); + Assert.Equal("test-extension-id", Assert.Single(handler.ClientIds)); // the same Client-Id a real publish sends + Assert.Equal("https://api.twitch.tv/helix/extensions/pubsub", Assert.Single(handler.Uris)!.ToString()); + } + + // --- real broadcasts keep the status honest ------------------------------------------------------ + + [Theory] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.Unauthorized)] + public async Task A_real_broadcast_refused_for_its_token_marks_the_credentials_rejected(HttpStatusCode code) + { + var (client, status) = PubSub(code); + + Assert.Equal(PubSubBroadcastResult.CredentialsRejected, await client.BroadcastAsync("chan", State(), CancellationToken.None)); + + Assert.Equal(TwitchCredentialState.Rejected, status.State); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] // the payload, not the credentials + [InlineData(HttpStatusCode.InternalServerError)] // Twitch, not the credentials + [InlineData(HttpStatusCode.TooManyRequests)] + public async Task Other_failures_say_nothing_about_the_credentials(HttpStatusCode code) + { + var (client, status) = PubSub(code); + + // An ordinary failure, reported as one: never as a credentials problem. + Assert.Equal(PubSubBroadcastResult.Failed, await client.BroadcastAsync("chan", State(), CancellationToken.None)); + + Assert.Equal(TwitchCredentialState.Unknown, status.State); + } + + [Fact] + public async Task A_real_broadcast_that_succeeds_clears_a_rejection_the_startup_check_recorded() + { + var status = new TwitchCredentialStatus(); + status.MarkRejected(403); + var (client, _) = PubSub(HttpStatusCode.NoContent, status); + + Assert.Equal(PubSubBroadcastResult.Published, await client.BroadcastAsync("chan", State(), CancellationToken.None)); + + Assert.Equal(TwitchCredentialState.Accepted, status.State); + Assert.Null(status.Current.HttpStatus); + } + + [Fact] + public async Task An_ordinary_failure_after_a_refusal_is_reported_as_one_and_does_not_change_the_status() + { + var status = new TwitchCredentialStatus(); + status.MarkRejected(403); + var (client, _) = PubSub(HttpStatusCode.InternalServerError, status); + + Assert.Equal(PubSubBroadcastResult.Failed, await client.BroadcastAsync("chan", State(), CancellationToken.None)); + + // Still what the last real refusal said: a 500 proves nothing about the credentials either way. + Assert.Equal(TwitchCredentialState.Rejected, status.State); + Assert.Equal(403, status.Current.HttpStatus); + } + + [Fact] + public async Task A_rejection_after_a_success_is_noticed_straight_away() + { + var status = new TwitchCredentialStatus(); + var (ok, _) = PubSub(HttpStatusCode.NoContent, status); + await ok.BroadcastAsync("chan", State(), CancellationToken.None); + var (refused, _) = PubSub(HttpStatusCode.Forbidden, status); + + await refused.BroadcastAsync("chan", State(), CancellationToken.None); + + Assert.Equal(TwitchCredentialState.Rejected, status.State); + } + + // --- what the commander's app and the operator see ----------------------------------------------- + + [Fact] + public async Task A_publish_Twitch_refuses_for_the_EBS_credentials_is_a_503_with_the_marker_header() + { + using var factory = new EbsHostFactory(); + var (client, _) = factory.CreateBroadcasterClient("chan-rejected"); + factory.PubSub.RejectCredentialsFor.Add("chan-rejected"); // Twitch refuses the EBS's credentials for this call + + var response = await client.PostAsJsonAsync("/api/update-state", new { state = new { v = 1, headline = "Docked at Home" } }); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal("credentials", Assert.Single(response.Headers.GetValues(TwitchCredentialStatus.RejectedHeader))); + Assert.Contains("credentials", await response.Content.ReadAsStringAsync()); + // The card is still stored, so a viewer who opens the channel gets it: only live pushes are lost. + Assert.Equal(HttpStatusCode.OK, (await factory.CreateClient().GetAsync("/api/initial-state/chan-rejected")).StatusCode); + } + + [Fact] + public async Task An_ordinary_PubSub_failure_is_still_a_plain_502_without_the_marker() + { + using var factory = new EbsHostFactory(); + var (client, _) = factory.CreateBroadcasterClient("chan-flaky"); + factory.PubSub.RejectFor.Add("chan-flaky"); // fails, but Twitch never said the credentials were refused + + var response = await client.PostAsJsonAsync("/api/update-state", new { state = new { v = 1, headline = "Docked at Home" } }); + + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + Assert.False(response.Headers.Contains(TwitchCredentialStatus.RejectedHeader)); + } + + [Fact] + public async Task A_stale_rejection_does_not_make_a_later_ordinary_failure_look_like_a_credentials_problem() + { + using var factory = new EbsHostFactory(); + var (client, _) = factory.CreateBroadcasterClient("chan-stale"); + // Some earlier call was refused, so the shared status says "rejected"... + factory.Services.GetRequiredService().MarkRejected(403); + // ...but THIS broadcast fails for an ordinary reason (a Twitch outage, a 400). + factory.PubSub.RejectFor.Add("chan-stale"); + + var response = await client.PostAsJsonAsync("/api/update-state", new { state = new { v = 1, headline = "Docked at Home" } }); + + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + Assert.False(response.Headers.Contains(TwitchCredentialStatus.RejectedHeader)); + } + + [Fact] + public async Task A_successful_publish_does_not_carry_the_marker() + { + using var factory = new EbsHostFactory(); + var (client, _) = factory.CreateBroadcasterClient("chan-fine"); + + var response = await client.PostAsJsonAsync("/api/update-state", new { state = new { v = 1, headline = "Docked at Home" } }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains(TwitchCredentialStatus.RejectedHeader)); + } + + [Fact] + public async Task Healthz_stays_ok_but_reports_what_is_known_about_the_credentials() + { + using var factory = new EbsHostFactory(); + var http = factory.CreateClient(); + var status = factory.Services.GetRequiredService(); + + var unknown = await ReadHealth(http); + Assert.Equal("ok", unknown.GetProperty("status").GetString()); + Assert.Equal("unknown", unknown.GetProperty("pubsub").GetString()); + + status.MarkRejected(403); + var rejected = await ReadHealth(http); + // Still "ok": a wrong secret must not get the container restarted in a loop, which is what a + // failing health check does and which would not fix it. + Assert.Equal("ok", rejected.GetProperty("status").GetString()); + Assert.Equal("rejected", rejected.GetProperty("pubsub").GetString()); + Assert.Equal(403, rejected.GetProperty("pubsubStatus").GetInt32()); + Assert.NotEqual(JsonValueKind.Null, rejected.GetProperty("pubsubCheckedAt").ValueKind); + + status.MarkAccepted(); + var accepted = await ReadHealth(http); + Assert.Equal("accepted", accepted.GetProperty("pubsub").GetString()); + Assert.Equal(JsonValueKind.Null, accepted.GetProperty("pubsubStatus").ValueKind); + } + + private static async Task ReadHealth(HttpClient http) + { + var response = await http.GetAsync("/healthz"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone(); + } + + // --- the background service ---------------------------------------------------------------------- + + [Fact] + public async Task One_run_asks_the_probe_once() + { + var probe = new CountingProbe(); + var service = new TwitchCredentialCheckBackgroundService(probe, new TestEnvironment("Production"), TimeProvider.System, NullLogger.Instance); + + await service.RunOnceAsync(CancellationToken.None); + + Assert.Equal(1, probe.Calls); + } + + [Fact] + public async Task A_probe_that_throws_cannot_take_the_host_down() + { + var probe = new CountingProbe { Throw = true }; + var service = new TwitchCredentialCheckBackgroundService(probe, new TestEnvironment("Production"), TimeProvider.System, NullLogger.Instance); + + await service.RunOnceAsync(CancellationToken.None); // does not throw + + Assert.Equal(1, probe.Calls); + } + + [Fact] + public async Task Under_the_test_host_it_never_reaches_out_at_all() + { + var probe = new CountingProbe(); + var service = new TwitchCredentialCheckBackgroundService(probe, new TestEnvironment("Testing"), TimeProvider.System, NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + await service.ExecuteTask!; + + Assert.Equal(0, probe.Calls); + } + + [Fact] + public async Task Shutting_down_before_the_first_check_stops_it_cleanly_without_asking_Twitch() + { + var probe = new CountingProbe(); + var service = new TwitchCredentialCheckBackgroundService(probe, new TestEnvironment("Production"), TimeProvider.System, NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + await service.StopAsync(CancellationToken.None); + + Assert.Equal(0, probe.Calls); // the 10 s initial delay had not passed + } + + // --- helpers ------------------------------------------------------------------------------------- + + private sealed class ThrowingHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => throw new HttpRequestException("network is down"); + } + + private sealed class CountingProbe : ITwitchCredentialProbe + { + public int Calls; + public bool Throw; + + public Task CheckAsync(CancellationToken cancellationToken) + { + Calls++; + if (Throw) throw new InvalidOperationException("boom"); + return Task.FromResult(TwitchCredentialProbeResult.Inconclusive); + } + } + + private sealed class TestEnvironment(string name) : IHostEnvironment + { + public string EnvironmentName { get; set; } = name; + public string ApplicationName { get; set; } = "test"; + public string ContentRootPath { get; set; } = ""; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/tests/EDNexus.Ebs.Tests/UpdateStateEndpointTests.cs b/tests/EDNexus.Ebs.Tests/UpdateStateEndpointTests.cs index 83c519f..72926f8 100644 --- a/tests/EDNexus.Ebs.Tests/UpdateStateEndpointTests.cs +++ b/tests/EDNexus.Ebs.Tests/UpdateStateEndpointTests.cs @@ -335,13 +335,17 @@ public sealed class FakeTwitchPubSubClient : ITwitchPubSubClient public Dictionary LastMessage { get; } = new(); - /// Channels whose broadcasts Twitch rejects. + /// Channels whose broadcasts fail for an ordinary reason (a bad payload, a Twitch outage). public HashSet RejectFor { get; } = new(); - public Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken) + /// Channels whose broadcasts fail because Twitch refuses the EBS's own credentials. + public HashSet RejectCredentialsFor { get; } = new(); + + public Task BroadcastAsync(string broadcasterId, JsonElement state, CancellationToken cancellationToken) { BroadcastCalls[broadcasterId] = BroadcastCalls.GetValueOrDefault(broadcasterId) + 1; LastMessage[broadcasterId] = state.Clone(); - return Task.FromResult(!RejectFor.Contains(broadcasterId)); + if (RejectCredentialsFor.Contains(broadcasterId)) return Task.FromResult(PubSubBroadcastResult.CredentialsRejected); + return Task.FromResult(RejectFor.Contains(broadcasterId) ? PubSubBroadcastResult.Failed : PubSubBroadcastResult.Published); } } diff --git a/tests/EDNexus.Tests/Twitch/StreamCardWarningTests.cs b/tests/EDNexus.Tests/Twitch/StreamCardWarningTests.cs new file mode 100644 index 0000000..974f736 --- /dev/null +++ b/tests/EDNexus.Tests/Twitch/StreamCardWarningTests.cs @@ -0,0 +1,40 @@ +using EDNexus.Core.Twitch; +using Xunit; + +namespace EDNexus.Tests.Twitch; + +public class StreamCardWarningTests +{ + private static StreamStatePublishResult Result(StreamStatePublishStatus status) => new(status, "x"); + + [Fact] + public void Twitch_refusing_the_backends_credentials_raises_a_warning_that_says_it_is_not_the_commanders_fault() + { + var warning = StreamCardWarning.For(Result(StreamStatePublishStatus.BackendRejectedByTwitch)); + + Assert.NotNull(warning); + Assert.Contains("credentials", warning); + Assert.Contains("live updates", warning); + Assert.Contains("not with your sign-in", warning); + } + + [Theory] + [InlineData(StreamStatePublishStatus.Published)] + [InlineData(StreamStatePublishStatus.Cleared)] + [InlineData(StreamStatePublishStatus.Unauthorized)] // has its own, different, prompt + [InlineData(StreamStatePublishStatus.TooLarge)] + [InlineData(StreamStatePublishStatus.RateLimited)] + [InlineData(StreamStatePublishStatus.Failed)] // a dropped connection is not a banner + [InlineData(StreamStatePublishStatus.ClearedNotDelivered)] + public void Nothing_else_raises_one(StreamStatePublishStatus status) + => Assert.Null(StreamCardWarning.For(Result(status))); + + [Theory] + [InlineData(StreamStatePublishStatus.Published, true)] + [InlineData(StreamStatePublishStatus.Cleared, true)] + [InlineData(StreamStatePublishStatus.Failed, false)] // proves nothing either way + [InlineData(StreamStatePublishStatus.RateLimited, false)] + [InlineData(StreamStatePublishStatus.BackendRejectedByTwitch, false)] + public void Only_a_success_clears_a_shown_warning(StreamStatePublishStatus status, bool clears) + => Assert.Equal(clears, StreamCardWarning.Clears(Result(status))); +} diff --git a/tests/EDNexus.Tests/Twitch/StreamStateApiClientTests.cs b/tests/EDNexus.Tests/Twitch/StreamStateApiClientTests.cs index 1d1ff8b..f8bcd61 100644 --- a/tests/EDNexus.Tests/Twitch/StreamStateApiClientTests.cs +++ b/tests/EDNexus.Tests/Twitch/StreamStateApiClientTests.cs @@ -70,6 +70,40 @@ public async Task An_unreachable_ebs_is_reported_not_thrown() Assert.Contains("no route to host", result.Error); } + [Fact] + public async Task Twitch_refusing_the_backends_credentials_is_told_apart_from_an_ordinary_failure() + { + // The EBS marks it with a header (a proxy can rewrite a 502's body) on a 503. + var refused = new StubHandler(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + response.Headers.Add(StreamStateApiClient.PubSubRejectedHeader, "credentials"); + return Task.FromResult(response); + }); + using (var client = new StreamStateApiClient(new HttpClient(refused))) + { + var result = await client.PublishAsync(Endpoint, "ebs-token", Snapshot()); + + Assert.Equal(StreamStatePublishStatus.BackendRejectedByTwitch, result.Status); + Assert.False(result.IsSuccess); + // Not the commander's sign-in: re-login is the wrong advice and must not stop publishing. + Assert.False(result.RequiresReauth); + } + + // The same status with no marker is just a failure, as before. + var plain = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable))); + using var plainClient = new StreamStateApiClient(new HttpClient(plain)); + Assert.Equal(StreamStatePublishStatus.Failed, (await plainClient.PublishAsync(Endpoint, "ebs-token", Snapshot())).Status); + } + + [Fact] + public void The_header_names_match_what_the_EBS_sends() + { + // Mirrored constants: the two projects cannot reference each other, so a typo would silently + // turn every credential problem back into an anonymous failure. + Assert.Equal("X-EDNexus-Pubsub-Rejected", StreamStateApiClient.PubSubRejectedHeader); + } + [Fact] public async Task A_clear_the_EBS_confirms_removed_is_told_apart_from_a_proxy_502() { diff --git a/tests/EDNexus.Tests/Twitch/TwitchStreamCardServiceTests.cs b/tests/EDNexus.Tests/Twitch/TwitchStreamCardServiceTests.cs index 2a8982c..2a06399 100644 --- a/tests/EDNexus.Tests/Twitch/TwitchStreamCardServiceTests.cs +++ b/tests/EDNexus.Tests/Twitch/TwitchStreamCardServiceTests.cs @@ -468,6 +468,89 @@ public async Task A_failed_publish_is_retried_without_waiting_for_a_change() Assert.True(attempts >= 3, $"Expected the pump to retry unprompted; it stopped after {attempts} attempt(s)."); } + [Fact] + public async Task A_backend_whose_credentials_Twitch_refuses_is_retried_and_recovers_once_fixed() + { + var state = new CommanderState { StarSystem = "Nervi" }; + var attempts = 0; + var client = new FakeStreamStateApiClient(); + // The operator fixes the secret while the app is running: no commander action is needed, so + // the pump must keep coming back by itself and the card must start updating again. + client.Respond = _ => ++attempts < 3 + ? new StreamStatePublishResult(StreamStatePublishStatus.BackendRejectedByTwitch, "HTTP 503") + : StreamStatePublishResult.Ok; + + using var service = Create(state, client); + service.RequestPublish(); + + var deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline && attempts < 3) + await Task.Delay(FastInterval * 2); + + Assert.True(attempts >= 3, $"Expected the pump to keep retrying a backend fault; it stopped after {attempts} attempt(s)."); + } + + [Fact] + public async Task A_backend_fault_keeps_being_retried_past_the_cap_so_an_idle_card_recovers_when_it_is_fixed() + { + var state = new CommanderState { StarSystem = "Nervi" }; + var attempts = 0; + var fixedAt = TwitchStreamCardService.MaxPublishRetries + 4; // well past the bounded budget + var client = new FakeStreamStateApiClient(); + // No successful publish ever happened this session, so the heartbeat is skipped too: the retry + // loop is the card's only way back, and an operator fixing the secret takes far longer than + // the handful of retries an ordinary failure gets. + client.Respond = _ => ++attempts < fixedAt + ? new StreamStatePublishResult(StreamStatePublishStatus.BackendRejectedByTwitch, "HTTP 503") + : StreamStatePublishResult.Ok; + + using var service = new TwitchStreamCardService( + state, StreamCardSources.Empty, client, static () => Endpoint, static () => "ebs-token", + visibility: null, isSuppressed: null, minInterval: TimeSpan.FromMilliseconds(5), clock: null); + service.RequestPublish(); + + var deadline = DateTime.UtcNow.AddSeconds(15); + while (DateTime.UtcNow < deadline && attempts < fixedAt) + await Task.Delay(50); + + Assert.True(attempts >= fixedAt, + $"Expected retries to continue past the cap until the backend recovered; stopped after {attempts} attempt(s)."); + } + + [Fact] + public void The_backend_fault_retry_is_slow_in_real_use_and_never_longer_than_the_retry_ceiling() + { + var state = new CommanderState(); + using var realistic = new TwitchStreamCardService( + state, StreamCardSources.Empty, new FakeStreamStateApiClient(), static () => Endpoint, static () => "t"); + using var tiny = new TwitchStreamCardService( + state, StreamCardSources.Empty, new FakeStreamStateApiClient(), static () => Endpoint, static () => "t", + visibility: null, isSuppressed: null, minInterval: TimeSpan.FromMilliseconds(5), clock: null); + + // At the default five-second floor: two minutes, i.e. one small request per two minutes. + Assert.Equal(TwitchStreamCardService.MaxRetryAfter, realistic.BackendFaultRetryInterval); + Assert.Equal(TimeSpan.FromMilliseconds(120), tiny.BackendFaultRetryInterval); + Assert.True(realistic.BackendFaultRetryInterval > realistic.MinInterval * 2, "must be slower than an ordinary retry"); + } + + [Fact] + public async Task A_backend_fault_does_not_stop_publishing_the_way_a_revoked_token_does() + { + var state = new CommanderState { StarSystem = "Nervi" }; + var reauth = 0; + var client = new FakeStreamStateApiClient + { + Respond = _ => new StreamStatePublishResult(StreamStatePublishStatus.BackendRejectedByTwitch, "HTTP 503"), + }; + + using var service = Create(state, client); + service.ReauthRequired += () => reauth++; + service.RequestPublish(); + await Task.Delay(FastInterval * 6); + + Assert.Equal(0, reauth); // the commander's sign-in is not the problem + } + [Fact] public async Task Retries_are_bounded_so_a_dead_ebs_is_not_polled_forever() {