fix(shell): auth-rejected hub no longer reported as unreachable
Some checks are pending
Security / Security check (push) Waiting to run
Some checks are pending
Security / Security check (push) Waiting to run
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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
33e5d35ac9
commit
f0f151fa7a
8 changed files with 127 additions and 5 deletions
|
|
@ -212,6 +212,10 @@ class HubService {
|
||||||
|
|
||||||
Future<bool> healthy() => _client.healthy();
|
Future<bool> 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<HubProbeResult> probeHealth() => _client.probe();
|
||||||
|
|
||||||
/// Configured module stores (+ the bundled seed) for the store manager.
|
/// Configured module stores (+ the bundled seed) for the store manager.
|
||||||
Future<List<StoreSource>> listStores() => _client.listStores();
|
Future<List<StoreSource>> listStores() => _client.listStores();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1671,6 +1671,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hubUnreachableOpenSettings": "Einstellungen öffnen",
|
"hubUnreachableOpenSettings": "Einstellungen öffnen",
|
||||||
|
"hubAuthRejectedBanner": "{endpoint} lehnt die Anmeldung ab — Zugriffstoken prüfen",
|
||||||
|
"@hubAuthRejectedBanner": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"navFederation": "Föderation",
|
"navFederation": "Föderation",
|
||||||
"federationTitle": "Föderation",
|
"federationTitle": "Föderation",
|
||||||
"federationReloadTooltip": "Satelliten neu laden",
|
"federationReloadTooltip": "Satelliten neu laden",
|
||||||
|
|
|
||||||
|
|
@ -1695,6 +1695,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hubUnreachableOpenSettings": "Open Settings",
|
"hubUnreachableOpenSettings": "Open Settings",
|
||||||
|
"hubAuthRejectedBanner": "{endpoint} rejected the sign-in — check the access token",
|
||||||
|
"@hubAuthRejectedBanner": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"navFederation": "Federation",
|
"navFederation": "Federation",
|
||||||
"federationTitle": "Federation",
|
"federationTitle": "Federation",
|
||||||
"federationReloadTooltip": "Reload satellites",
|
"federationReloadTooltip": "Reload satellites",
|
||||||
|
|
|
||||||
|
|
@ -4903,6 +4903,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Open Settings'**
|
/// **'Open Settings'**
|
||||||
String get hubUnreachableOpenSettings;
|
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.
|
/// No description provided for @navFederation.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2890,6 +2890,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get hubUnreachableOpenSettings => 'Einstellungen öffnen';
|
String get hubUnreachableOpenSettings => 'Einstellungen öffnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String hubAuthRejectedBanner(String endpoint) {
|
||||||
|
return '$endpoint lehnt die Anmeldung ab — Zugriffstoken prüfen';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navFederation => 'Föderation';
|
String get navFederation => 'Föderation';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2891,6 +2891,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get hubUnreachableOpenSettings => 'Open Settings';
|
String get hubUnreachableOpenSettings => 'Open Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String hubAuthRejectedBanner(String endpoint) {
|
||||||
|
return '$endpoint rejected the sign-in — check the access token';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navFederation => 'Federation';
|
String get navFederation => 'Federation';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,18 @@ class StudioShellState extends State<StudioShell> {
|
||||||
/// flash the banner — only sustained failure does.
|
/// flash the banner — only sustained failure does.
|
||||||
bool get _hubUnreachable => _failedPolls >= _unreachableThreshold;
|
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<HubProbeResult> Function()? debugProbeOverride;
|
||||||
|
|
||||||
/// Current connection state, for descendants (e.g. WelcomePage)
|
/// Current connection state, for descendants (e.g. WelcomePage)
|
||||||
/// that adapt their content to hub availability.
|
/// that adapt their content to hub availability.
|
||||||
bool? get connected => _connected;
|
bool? get connected => _connected;
|
||||||
|
|
@ -411,17 +423,25 @@ class StudioShellState extends State<StudioShell> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkHealth() async {
|
Future<void> _checkHealth() async {
|
||||||
final ok = await HubService.instance.healthy();
|
final probe = debugProbeOverride != null
|
||||||
|
? await debugProbeOverride!()
|
||||||
|
: await HubService.instance.probeHealth();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
final ok = probe == HubProbeResult.serving;
|
||||||
|
final authRejected = probe == HubProbeResult.authRejected;
|
||||||
final wasUnreachable = _hubUnreachable;
|
final wasUnreachable = _hubUnreachable;
|
||||||
final nextFailed = ok ? 0 : _failedPolls + 1;
|
final nextFailed = ok ? 0 : _failedPolls + 1;
|
||||||
final connectionChanged = _connected != ok;
|
final connectionChanged = _connected != ok;
|
||||||
final bannerChanged =
|
final bannerChanged =
|
||||||
wasUnreachable != (nextFailed >= _unreachableThreshold);
|
wasUnreachable != (nextFailed >= _unreachableThreshold);
|
||||||
if (connectionChanged || _failedPolls != nextFailed || bannerChanged) {
|
if (connectionChanged ||
|
||||||
|
_failedPolls != nextFailed ||
|
||||||
|
bannerChanged ||
|
||||||
|
_authRejected != authRejected) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_connected = ok;
|
_connected = ok;
|
||||||
_failedPolls = nextFailed;
|
_failedPolls = nextFailed;
|
||||||
|
_authRejected = authRejected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|
@ -557,6 +577,7 @@ class StudioShellState extends State<StudioShell> {
|
||||||
if (_hubUnreachable)
|
if (_hubUnreachable)
|
||||||
_HubUnreachableBanner(
|
_HubUnreachableBanner(
|
||||||
endpoint: HubService.instance.endpointLabel,
|
endpoint: HubService.instance.endpointLabel,
|
||||||
|
authRejected: _authRejected,
|
||||||
onOpenSettings: () => ChainSettingsDialog.show(context),
|
onOpenSettings: () => ChainSettingsDialog.show(context),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -595,13 +616,17 @@ class StudioShellState extends State<StudioShell> {
|
||||||
/// One-line banner shown when the hub has been unreachable for
|
/// One-line banner shown when the hub has been unreachable for
|
||||||
/// several consecutive health polls. Replaces the indefinite
|
/// several consecutive health polls. Replaces the indefinite
|
||||||
/// silent "connecting…" with an explicit message + a jump to
|
/// 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 {
|
class _HubUnreachableBanner extends StatelessWidget {
|
||||||
final String endpoint;
|
final String endpoint;
|
||||||
|
final bool authRejected;
|
||||||
final VoidCallback onOpenSettings;
|
final VoidCallback onOpenSettings;
|
||||||
|
|
||||||
const _HubUnreachableBanner({
|
const _HubUnreachableBanner({
|
||||||
required this.endpoint,
|
required this.endpoint,
|
||||||
|
required this.authRejected,
|
||||||
required this.onOpenSettings,
|
required this.onOpenSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -619,14 +644,16 @@ class _HubUnreachableBanner extends StatelessWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.cloud_off_outlined,
|
authRejected ? Icons.key_off_outlined : Icons.cloud_off_outlined,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: theme.colorScheme.onErrorContainer,
|
color: theme.colorScheme.onErrorContainer,
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
l.hubUnreachableBanner(endpoint),
|
authRejected
|
||||||
|
? l.hubAuthRejectedBanner(endpoint)
|
||||||
|
: l.hubUnreachableBanner(endpoint),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onErrorContainer,
|
color: theme.colorScheme.onErrorContainer,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
63
test/hub_banner_test.dart
Normal file
63
test/hub_banner_test.dart
Normal file
|
|
@ -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<void> _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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue