From f0f151fa7a40d9a6ddfbc595200e30d71928c002 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 15 Jul 2026 04:45:28 +0200 Subject: [PATCH] fix(shell): auth-rejected hub no longer reported as unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sustained-failure banner treated every failed health poll as 'can't reach the hub'. With token auth active, a wrong or rotated token gets UNAUTHENTICATED from a perfectly reachable hub — the old wording sent the operator to fix the endpoint. The shell now uses the SDK's probe() and, on auth rejection, switches the banner to 'rejected the sign-in — check the access token' (key-off icon, DE+EN). Two widget tests pin the wording per failure kind and the banner clearing once the probe turns serving. Signed-off-by: flemming-it --- lib/data/hub.dart | 4 ++ lib/l10n/app_de.arb | 6 +++ lib/l10n/app_en.arb | 6 +++ lib/l10n/app_localizations.dart | 6 +++ lib/l10n/app_localizations_de.dart | 5 +++ lib/l10n/app_localizations_en.dart | 5 +++ lib/main.dart | 37 +++++++++++++++--- test/hub_banner_test.dart | 63 ++++++++++++++++++++++++++++++ 8 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 test/hub_banner_test.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index ac140ce..b5eecc0 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -212,6 +212,10 @@ class HubService { Future healthy() => _client.healthy(); + /// Health probe that keeps the failure kind: an auth-rejected + /// hub is up and needs a token fix, not an endpoint fix. + Future probeHealth() => _client.probe(); + /// Configured module stores (+ the bundled seed) for the store manager. Future> listStores() => _client.listStores(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 59c133d..9fb299b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1671,6 +1671,12 @@ } }, "hubUnreachableOpenSettings": "Einstellungen öffnen", + "hubAuthRejectedBanner": "{endpoint} lehnt die Anmeldung ab — Zugriffstoken prüfen", + "@hubAuthRejectedBanner": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, "navFederation": "Föderation", "federationTitle": "Föderation", "federationReloadTooltip": "Satelliten neu laden", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e33fea0..02845a2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1695,6 +1695,12 @@ } }, "hubUnreachableOpenSettings": "Open Settings", + "hubAuthRejectedBanner": "{endpoint} rejected the sign-in — check the access token", + "@hubAuthRejectedBanner": { + "placeholders": { + "endpoint": {"type": "String"} + } + }, "navFederation": "Federation", "federationTitle": "Federation", "federationReloadTooltip": "Reload satellites", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 097ed4b..f7c7ca7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4903,6 +4903,12 @@ abstract class AppLocalizations { /// **'Open Settings'** String get hubUnreachableOpenSettings; + /// No description provided for @hubAuthRejectedBanner. + /// + /// In en, this message translates to: + /// **'{endpoint} rejected the sign-in — check the access token'** + String hubAuthRejectedBanner(String endpoint); + /// No description provided for @navFederation. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d4696b4..0e25984 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2890,6 +2890,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get hubUnreachableOpenSettings => 'Einstellungen öffnen'; + @override + String hubAuthRejectedBanner(String endpoint) { + return '$endpoint lehnt die Anmeldung ab — Zugriffstoken prüfen'; + } + @override String get navFederation => 'Föderation'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d6d191c..1c1908a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2891,6 +2891,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get hubUnreachableOpenSettings => 'Open Settings'; + @override + String hubAuthRejectedBanner(String endpoint) { + return '$endpoint rejected the sign-in — check the access token'; + } + @override String get navFederation => 'Federation'; diff --git a/lib/main.dart b/lib/main.dart index 0ed86ee..bc52244 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -275,6 +275,18 @@ class StudioShellState extends State { /// flash the banner — only sustained failure does. bool get _hubUnreachable => _failedPolls >= _unreachableThreshold; + /// True when the last failed poll was an auth rejection + /// (UNAUTHENTICATED / PERMISSION_DENIED): the hub is up, the + /// token is wrong. The banner must say so — "can't reach the + /// hub" would send the operator to fix the wrong thing. + bool _authRejected = false; + + /// Test-only: replaces the hub health probe so widget tests can + /// simulate sustained unreachable / auth-rejected states + /// without a live hub. + @visibleForTesting + static Future Function()? debugProbeOverride; + /// Current connection state, for descendants (e.g. WelcomePage) /// that adapt their content to hub availability. bool? get connected => _connected; @@ -411,17 +423,25 @@ class StudioShellState extends State { } Future _checkHealth() async { - final ok = await HubService.instance.healthy(); + final probe = debugProbeOverride != null + ? await debugProbeOverride!() + : await HubService.instance.probeHealth(); if (!mounted) return; + final ok = probe == HubProbeResult.serving; + final authRejected = probe == HubProbeResult.authRejected; final wasUnreachable = _hubUnreachable; final nextFailed = ok ? 0 : _failedPolls + 1; final connectionChanged = _connected != ok; final bannerChanged = wasUnreachable != (nextFailed >= _unreachableThreshold); - if (connectionChanged || _failedPolls != nextFailed || bannerChanged) { + if (connectionChanged || + _failedPolls != nextFailed || + bannerChanged || + _authRejected != authRejected) { setState(() { _connected = ok; _failedPolls = nextFailed; + _authRejected = authRejected; }); } if (ok) { @@ -557,6 +577,7 @@ class StudioShellState extends State { if (_hubUnreachable) _HubUnreachableBanner( endpoint: HubService.instance.endpointLabel, + authRejected: _authRejected, onOpenSettings: () => ChainSettingsDialog.show(context), ), Expanded( @@ -595,13 +616,17 @@ class StudioShellState extends State { /// One-line banner shown when the hub has been unreachable for /// several consecutive health polls. Replaces the indefinite /// silent "connecting…" with an explicit message + a jump to -/// Settings, where the operator can fix the endpoint. +/// Settings, where the operator can fix the endpoint. When the +/// failure is an auth rejection the wording flips to "check the +/// access token" — the endpoint is fine in that case. class _HubUnreachableBanner extends StatelessWidget { final String endpoint; + final bool authRejected; final VoidCallback onOpenSettings; const _HubUnreachableBanner({ required this.endpoint, + required this.authRejected, required this.onOpenSettings, }); @@ -619,14 +644,16 @@ class _HubUnreachableBanner extends StatelessWidget { child: Row( children: [ Icon( - Icons.cloud_off_outlined, + authRejected ? Icons.key_off_outlined : Icons.cloud_off_outlined, size: 18, color: theme.colorScheme.onErrorContainer, ), const SizedBox(width: ChainSpace.sm), Expanded( child: Text( - l.hubUnreachableBanner(endpoint), + authRejected + ? l.hubAuthRejectedBanner(endpoint) + : l.hubUnreachableBanner(endpoint), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onErrorContainer, ), diff --git a/test/hub_banner_test.dart b/test/hub_banner_test.dart new file mode 100644 index 0000000..6610a92 --- /dev/null +++ b/test/hub_banner_test.dart @@ -0,0 +1,63 @@ +// The shell's sustained-failure banner must name the actual +// problem: a hub that rejects the token (UNAUTHENTICATED / +// PERMISSION_DENIED) is UP — telling the operator "can't reach +// the hub" sends them to fix the endpoint when the token is what +// needs attention. The probe result is faked through +// StudioShellState.debugProbeOverride; the banner appears after +// three consecutive failed polls (5 s apart), so each test pumps +// the poll timer forward instead of waiting. + +import 'package:chain_client_sdk/chain_client_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/main.dart'; + +Future _pumpShell(WidgetTester tester) async { + await tester.pumpWidget( + const StudioApp( + initialThemeMode: ThemeModeValue.system, + initialLocale: Locale('en'), + ), + ); + // pump (not pumpAndSettle): the app has long-lived timers that + // never settle. One frame lays the shell out, then three poll + // ticks cross the banner threshold. + await tester.pump(const Duration(milliseconds: 100)); + for (var i = 0; i < 3; i++) { + await tester.pump(const Duration(seconds: 5)); + await tester.pump(); + } +} + +void main() { + tearDown(() { + StudioShellState.debugProbeOverride = null; + }); + + testWidgets('sustained auth rejection points at the token, not the wire', + (tester) async { + StudioShellState.debugProbeOverride = + () async => HubProbeResult.authRejected; + await _pumpShell(tester); + + expect(find.textContaining('rejected the sign-in'), findsOneWidget); + expect(find.textContaining("Can't reach"), findsNothing); + + // Token fixed (probe turns serving): the banner must clear. + StudioShellState.debugProbeOverride = () async => HubProbeResult.serving; + await tester.pump(const Duration(seconds: 5)); + await tester.pump(); + expect(find.textContaining('rejected the sign-in'), findsNothing); + }); + + testWidgets('sustained connection failure keeps the unreachable wording', + (tester) async { + StudioShellState.debugProbeOverride = + () async => HubProbeResult.unreachable; + await _pumpShell(tester); + + expect(find.textContaining("Can't reach"), findsOneWidget); + expect(find.textContaining('rejected the sign-in'), findsNothing); + }); +}