fix(shell): daemon-start and health-poll auth handling; policy panel freshness
Some checks failed
Security / Security check (push) Failing after 2s
Some checks failed
Security / Security check (push) Failing after 2s
Review follow-ups on the auth-status work: - Both daemon-start paths classified an auth-rejected hub as "daemon dead" via healthy() and showed a start-failure dialog while the shell banner above correctly blamed the token. They now share daemonAnswers(): only an unreachable probe counts as down. - An auth-rejected poll now re-reads ~/.chain/hub-auth-token and reconnects when the file changed, so a token fixed outside Studio (CLI, editor) heals the connection without a restart — previously the client kept the stale in-memory token forever and the banner's own advice could not work. - An endpoint switch resets the failure streak, so a stale in-flight probe can no longer let the unreachable banner blame the new endpoint for the old one's misses. - The auth-policy panel re-queries when the hub token is saved or cleared in the panel above (reloadTick), instead of keeping a stale admin-denied hint; it also renders the hub's new reload_required flag as a pending-reload warning (DE+EN). - today-pipeline.md still documented ~/.fai/today after the rename; the FAB theme comment now states the both-themes intent. flutter analyze clean; 71 tests green including four new ones. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
2a1cbc82c3
commit
7cc8bab9b9
13 changed files with 242 additions and 16 deletions
|
|
@ -24,11 +24,11 @@ feeds the accepted story straight into Studio without a code release.
|
|||
│ 2. PROPOSER tools/today/propose.sh │
|
||||
│ local System-AI (Ollama by default) drafts 3 story candidates │
|
||||
│ using tools/today/prompt.template.md + the signal summary → │
|
||||
│ ~/.fai/today/proposals/<ISO-DATE>-<n>.yaml │
|
||||
│ ~/.chain/today/proposals/<ISO-DATE>-<n>.yaml │
|
||||
│ │
|
||||
│ 3. REVIEW + ACCEPT tools/today/accept.sh <proposal-id> │
|
||||
│ operator skims the proposal files, picks one (or none) → │
|
||||
│ ~/.fai/today/active.yaml │
|
||||
│ ~/.chain/today/active.yaml │
|
||||
│ │
|
||||
│ 4. STUDIO lib/pages/store.dart │
|
||||
│ Studio reads active.yaml at startup; falls back to the │
|
||||
|
|
@ -43,7 +43,7 @@ agent (macOS), systemd timer (Linux), or `cron` — see
|
|||
|
||||
## Schema
|
||||
|
||||
`~/.fai/today/active.yaml` (and every proposal file) follows this shape:
|
||||
`~/.chain/today/active.yaml` (and every proposal file) follows this shape:
|
||||
|
||||
```yaml
|
||||
schema: today/v1
|
||||
|
|
@ -111,6 +111,6 @@ The prompt template (`tools/today/prompt.template.md`) hard-encodes:
|
|||
- A/B-testing or click-through tracking — Ch∆In has no surveillance budget
|
||||
and no telemetry pipeline to feed it into.
|
||||
- Auto-publication to git (operator-curated boundary stays manual).
|
||||
- Hub-served stories. Studio reads `~/.fai/today/active.yaml` directly
|
||||
- Hub-served stories. Studio reads `~/.chain/today/active.yaml` directly
|
||||
for now; promoting this to a HubAdmin RPC is a Phase 1 question once
|
||||
multi-tenant Studio appears.
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ class HubService {
|
|||
? await HubAuthToken.read()
|
||||
: authToken as String?;
|
||||
_client = HubClient(endpoint: endpoint, authToken: token);
|
||||
_lastAuthToken = token;
|
||||
if (persist) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kHostKey, endpoint.host);
|
||||
|
|
@ -177,6 +178,23 @@ class HubService {
|
|||
await reconnect(_client.endpoint);
|
||||
}
|
||||
|
||||
/// The token the live client was built with. The client caches it,
|
||||
/// so an edit to `~/.chain/hub-auth-token` outside Studio is
|
||||
/// invisible until a reconnect — see [reloadAuthTokenIfChanged].
|
||||
String? _lastAuthToken;
|
||||
|
||||
/// Re-read the token file and reconnect ONLY when its content
|
||||
/// differs from the token the live client uses. Returns whether a
|
||||
/// reconnect happened. The shell's health poll calls this on an
|
||||
/// auth-rejected probe so a token fixed outside Studio (CLI,
|
||||
/// editor) heals the connection without a restart.
|
||||
Future<bool> reloadAuthTokenIfChanged() async {
|
||||
final fresh = await HubAuthToken.read();
|
||||
if (fresh == _lastAuthToken) return false;
|
||||
await reconnect(_client.endpoint, authToken: fresh, persist: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
static const _kThemeKey = 'theme.mode';
|
||||
static const _kLocaleKey = 'locale.code';
|
||||
|
||||
|
|
@ -363,6 +381,7 @@ class HubService {
|
|||
scopeClaim: r.jwt.scopeClaim,
|
||||
)
|
||||
: null,
|
||||
reloadRequired: r.reloadRequired,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1685,11 +1704,16 @@ class HubAuthPolicy {
|
|||
/// jwt-rs256 parameters; null for the static validator.
|
||||
final HubJwtValidatorInfo? jwt;
|
||||
|
||||
/// True when the on-disk auth config (or its env vars) no longer
|
||||
/// matches the validator the hub enforces — a pending reload.
|
||||
final bool reloadRequired;
|
||||
|
||||
const HubAuthPolicy({
|
||||
required this.validator,
|
||||
required this.anonymousAllowed,
|
||||
required this.tokens,
|
||||
this.jwt,
|
||||
this.reloadRequired = false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -835,6 +835,7 @@
|
|||
"authPolicyValidatorStatic": "Prüfverfahren: statische Token-Liste",
|
||||
"authPolicyValidatorJwt": "Prüfverfahren: JWT (RS256) über eine externe Identitätsstelle",
|
||||
"authPolicyAnonymous": "Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.",
|
||||
"authPolicyReloadRequired": "Die Konfigurationsdatei (oder eine Umgebungsvariable) wurde seit dem Laden geändert — der Hub erzwingt noch den alten Stand. Unten „Neu laden“ wählen, damit die Änderung wirkt.",
|
||||
"authPolicyNeedsAdmin": "Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.",
|
||||
"authPolicyHubTooOld": "Der verbundene Hub kennt diese Ansicht noch nicht — er ist älter als Studio. Aktualisieren Sie den Hub (chain update apply) und laden Sie neu.",
|
||||
"authPolicyRetry": "Erneut versuchen",
|
||||
|
|
|
|||
|
|
@ -853,6 +853,7 @@
|
|||
"authPolicyValidatorStatic": "Validator: static token list",
|
||||
"authPolicyValidatorJwt": "Validator: JWT (RS256) via an external identity provider",
|
||||
"authPolicyAnonymous": "No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.",
|
||||
"authPolicyReloadRequired": "The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.",
|
||||
"authPolicyNeedsAdmin": "This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.",
|
||||
"authPolicyHubTooOld": "The connected hub does not know this view yet — it is older than Studio. Update the hub (chain update apply) and reload.",
|
||||
"authPolicyRetry": "Retry",
|
||||
|
|
|
|||
|
|
@ -2654,6 +2654,12 @@ abstract class AppLocalizations {
|
|||
/// **'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.'**
|
||||
String get authPolicyAnonymous;
|
||||
|
||||
/// No description provided for @authPolicyReloadRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.'**
|
||||
String get authPolicyReloadRequired;
|
||||
|
||||
/// No description provided for @authPolicyNeedsAdmin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1514,6 +1514,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get authPolicyAnonymous =>
|
||||
'Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.';
|
||||
|
||||
@override
|
||||
String get authPolicyReloadRequired =>
|
||||
'Die Konfigurationsdatei (oder eine Umgebungsvariable) wurde seit dem Laden geändert — der Hub erzwingt noch den alten Stand. Unten „Neu laden“ wählen, damit die Änderung wirkt.';
|
||||
|
||||
@override
|
||||
String get authPolicyNeedsAdmin =>
|
||||
'Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.';
|
||||
|
|
|
|||
|
|
@ -1527,6 +1527,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get authPolicyAnonymous =>
|
||||
'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.';
|
||||
|
||||
@override
|
||||
String get authPolicyReloadRequired =>
|
||||
'The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.';
|
||||
|
||||
@override
|
||||
String get authPolicyNeedsAdmin =>
|
||||
'This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.';
|
||||
|
|
|
|||
|
|
@ -270,6 +270,11 @@ class StudioShellState extends State<StudioShell> {
|
|||
int _failedPolls = 0;
|
||||
static const int _unreachableThreshold = 3;
|
||||
|
||||
/// Endpoint the previous poll ran against; a change resets the
|
||||
/// failure streak so stale in-flight results never blame the new
|
||||
/// endpoint (see `_checkHealth`).
|
||||
String _polledEndpoint = '';
|
||||
|
||||
/// Whether to render the unreachable banner. Distinct from
|
||||
/// `_connected == false` so a single transient miss doesn't
|
||||
/// flash the banner — only sustained failure does.
|
||||
|
|
@ -287,6 +292,44 @@ class StudioShellState extends State<StudioShell> {
|
|||
@visibleForTesting
|
||||
static Future<HubProbeResult> Function()? debugProbeOverride;
|
||||
|
||||
/// Test-only: replaces the token-file self-heal check (see
|
||||
/// [_reloadTokenIfChanged]) so widget tests can assert the
|
||||
/// auth-rejected poll re-reads the token without touching disk.
|
||||
@visibleForTesting
|
||||
static Future<bool> Function()? debugReloadTokenOverride;
|
||||
|
||||
/// Whether the hub daemon answers at all — an auth rejection or a
|
||||
/// not-serving health state still means the process is alive, just
|
||||
/// not usable yet; only [HubProbeResult.unreachable] is "down".
|
||||
/// Used by the daemon-start paths so a wrong token doesn't
|
||||
/// misreport as "daemon start failed" (the shell banner already
|
||||
/// explains the token problem).
|
||||
static Future<bool> daemonAnswers() async {
|
||||
try {
|
||||
final probe = debugProbeOverride != null
|
||||
? await debugProbeOverride!()
|
||||
: await HubService.instance.probeHealth();
|
||||
return probe != HubProbeResult.unreachable;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-heal after an auth-rejected poll: the operator may have
|
||||
/// fixed `~/.chain/hub-auth-token` outside Studio (CLI, editor) —
|
||||
/// the client caches the token, so re-read the file and reconnect
|
||||
/// when it changed. Returns whether a reconnect happened. Inert
|
||||
/// when the probe is test-overridden, unless the reload override
|
||||
/// is set too.
|
||||
static Future<bool> _reloadTokenIfChanged() {
|
||||
final override = debugReloadTokenOverride;
|
||||
if (override != null) return override();
|
||||
if (debugProbeOverride != null) return Future.value(false);
|
||||
return HubService.instance
|
||||
.reloadAuthTokenIfChanged()
|
||||
.catchError((_) => false);
|
||||
}
|
||||
|
||||
/// Current connection state, for descendants (e.g. WelcomePage)
|
||||
/// that adapt their content to hub availability.
|
||||
bool? get connected => _connected;
|
||||
|
|
@ -307,9 +350,9 @@ class StudioShellState extends State<StudioShell> {
|
|||
}
|
||||
// The start command reported failure — but very often the daemon is
|
||||
// simply already running (port in use). Probe before crying error,
|
||||
// so "tap to start" on an already-up hub just connects.
|
||||
final alive =
|
||||
await HubService.instance.healthy().catchError((_) => false);
|
||||
// so "tap to start" on an already-up hub just connects. Alive but
|
||||
// auth-rejected counts as running — the banner explains the token.
|
||||
final alive = await daemonAnswers();
|
||||
if (!mounted) return;
|
||||
if (alive) {
|
||||
await _checkHealth();
|
||||
|
|
@ -422,15 +465,22 @@ class StudioShellState extends State<StudioShell> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _checkHealth() async {
|
||||
Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
|
||||
final probe = debugProbeOverride != null
|
||||
? await debugProbeOverride!()
|
||||
: await HubService.instance.probeHealth();
|
||||
if (!mounted) return;
|
||||
// A failure that raced an endpoint switch (Settings save, channel
|
||||
// switch) must not inherit the old endpoint's failure streak —
|
||||
// restart the count so the banner never blames the NEW endpoint
|
||||
// for the OLD one's misses.
|
||||
final endpoint = HubService.instance.endpointLabel;
|
||||
final endpointChanged = endpoint != _polledEndpoint;
|
||||
_polledEndpoint = endpoint;
|
||||
final ok = probe == HubProbeResult.serving;
|
||||
final authRejected = probe == HubProbeResult.authRejected;
|
||||
final wasUnreachable = _hubUnreachable;
|
||||
final nextFailed = ok ? 0 : _failedPolls + 1;
|
||||
final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1);
|
||||
final connectionChanged = _connected != ok;
|
||||
final bannerChanged =
|
||||
wasUnreachable != (nextFailed >= _unreachableThreshold);
|
||||
|
|
@ -444,6 +494,16 @@ class StudioShellState extends State<StudioShell> {
|
|||
_authRejected = authRejected;
|
||||
});
|
||||
}
|
||||
if (authRejected && !retriedAfterTokenReload) {
|
||||
// Self-heal: re-read the token file in case it was fixed
|
||||
// outside Studio; on a real change reconnect + re-probe now
|
||||
// instead of waiting out the poll interval. One retry per
|
||||
// poll tick at most.
|
||||
final changed = await _reloadTokenIfChanged();
|
||||
if (changed && mounted) {
|
||||
return _checkHealth(retriedAfterTokenReload: true);
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
try {
|
||||
final snap = await HubService.instance.channelStatus();
|
||||
|
|
@ -779,8 +839,9 @@ class _SidebarState extends State<_Sidebar>
|
|||
}
|
||||
// The daemon may already be running (port in use) — probe before
|
||||
// showing an error, so this just connects on an already-up hub.
|
||||
final alive =
|
||||
await HubService.instance.healthy().catchError((_) => false);
|
||||
// Alive but auth-rejected counts as running — the shell banner
|
||||
// explains the token problem.
|
||||
final alive = await StudioShellState.daemonAnswers();
|
||||
if (!context.mounted) return;
|
||||
if (alive) return;
|
||||
// Genuinely down: persistent, copyable dialog (a SnackBar flashes
|
||||
|
|
|
|||
|
|
@ -265,9 +265,11 @@ class ChainTheme {
|
|||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
// Match FABs to the filled-button accent: Material 3 defaults
|
||||
// them to primaryContainer, which reads as a washed-out tonal
|
||||
// surface next to the sky-700 CTAs in the light theme.
|
||||
// Match FABs to the filled-button accent in BOTH themes:
|
||||
// Material 3 defaults them to primaryContainer, a tonal
|
||||
// surface that reads washed-out next to the primary CTAs
|
||||
// (visually verified light and dark; the dark FAB follows
|
||||
// the dark filled buttons on purpose).
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
|||
/// after save / clear; UI only ever sees the trimmed length.
|
||||
int? _hubAuthTokenChars;
|
||||
|
||||
/// Bumped whenever the hub auth token is saved or cleared so the
|
||||
/// auth-policy panel below re-queries with the new credentials.
|
||||
int _authPolicyReloadTick = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -95,6 +99,9 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
|||
await HubService.instance.reloadAuthToken();
|
||||
await _loadHubAuthToken();
|
||||
if (!mounted) return;
|
||||
// The connection's auth just changed — the policy panel below
|
||||
// must re-query instead of keeping a stale admin-denied hint.
|
||||
setState(() => _authPolicyReloadTick++);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
|
|
@ -111,6 +118,7 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
|||
await HubService.instance.reloadAuthToken();
|
||||
await _loadHubAuthToken();
|
||||
if (!mounted) return;
|
||||
setState(() => _authPolicyReloadTick++);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
|
|
@ -650,7 +658,7 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
|||
onClear: _clearHubAuthToken,
|
||||
),
|
||||
const SizedBox(height: ChainSpace.xl),
|
||||
const HubAuthPolicyPanel(),
|
||||
HubAuthPolicyPanel(reloadTick: _authPolicyReloadTick),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,18 @@ class HubAuthPolicyPanel extends StatefulWidget {
|
|||
@visibleForTesting
|
||||
final Future<int> Function()? reloader;
|
||||
|
||||
const HubAuthPolicyPanel({super.key, this.loader, this.reloader});
|
||||
/// Bumped by the parent whenever an adjacent action changed the
|
||||
/// connection's auth state (token saved or cleared in the panel
|
||||
/// above) — the policy view reloads instead of showing a stale
|
||||
/// "store an admin token" hint next to the just-stored token.
|
||||
final int reloadTick;
|
||||
|
||||
const HubAuthPolicyPanel({
|
||||
super.key,
|
||||
this.loader,
|
||||
this.reloader,
|
||||
this.reloadTick = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HubAuthPolicyPanel> createState() => _HubAuthPolicyPanelState();
|
||||
|
|
@ -42,6 +53,14 @@ class _HubAuthPolicyPanelState extends State<HubAuthPolicyPanel> {
|
|||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HubAuthPolicyPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.reloadTick != oldWidget.reloadTick) {
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
|
|
@ -193,6 +212,15 @@ class _HubAuthPolicyPanelState extends State<HubAuthPolicyPanel> {
|
|||
color: theme.colorScheme.tertiary,
|
||||
),
|
||||
],
|
||||
if (p.reloadRequired) ...[
|
||||
const SizedBox(height: ChainSpace.xs),
|
||||
_hintRow(
|
||||
theme,
|
||||
Icons.sync_problem_outlined,
|
||||
l.authPolicyReloadRequired,
|
||||
color: theme.colorScheme.tertiary,
|
||||
),
|
||||
],
|
||||
if (p.tokens.isNotEmpty) ...[
|
||||
const SizedBox(height: ChainSpace.sm),
|
||||
for (final t in p.tokens) _tokenRow(theme, l, t),
|
||||
|
|
|
|||
|
|
@ -156,4 +156,54 @@ void main() {
|
|||
expect(find.textContaining('admin-Recht'), findsOneWidget);
|
||||
expect(find.text('Erneut versuchen'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('reload-required policy shows the pending-reload hint', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
HubAuthPolicyPanel(
|
||||
loader: () async => const HubAuthPolicy(
|
||||
validator: 'static',
|
||||
anonymousAllowed: false,
|
||||
tokens: [],
|
||||
reloadRequired: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.textContaining('erzwingt noch den alten Stand'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a bumped reloadTick re-queries the policy', (tester) async {
|
||||
var loads = 0;
|
||||
Widget hostWithTick(int tick) => _host(
|
||||
HubAuthPolicyPanel(
|
||||
loader: () async {
|
||||
loads++;
|
||||
return _staticPolicy();
|
||||
},
|
||||
reloadTick: tick,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(hostWithTick(0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(loads, 1);
|
||||
|
||||
// Same tick: no reload on unrelated rebuilds.
|
||||
await tester.pumpWidget(hostWithTick(0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(loads, 1);
|
||||
|
||||
// Bumped tick (token saved next door): the panel re-queries.
|
||||
await tester.pumpWidget(hostWithTick(1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(loads, 2);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ Future<void> _pumpShell(WidgetTester tester) async {
|
|||
void main() {
|
||||
tearDown(() {
|
||||
StudioShellState.debugProbeOverride = null;
|
||||
StudioShellState.debugReloadTokenOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('sustained auth rejection points at the token, not the wire',
|
||||
|
|
@ -60,4 +61,40 @@ void main() {
|
|||
expect(find.textContaining("Can't reach"), findsOneWidget);
|
||||
expect(find.textContaining('rejected the sign-in'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('auth-rejected poll re-reads the token file and re-probes',
|
||||
(tester) async {
|
||||
// First probe(s) rejected; after the token file "changes" the
|
||||
// immediate re-probe serves — the operator fixed the token via
|
||||
// CLI/editor and Studio healed without waiting a poll cycle.
|
||||
var probes = 0;
|
||||
var reloads = 0;
|
||||
StudioShellState.debugProbeOverride = () async {
|
||||
probes++;
|
||||
return reloads > 0 ? HubProbeResult.serving : HubProbeResult.authRejected;
|
||||
};
|
||||
StudioShellState.debugReloadTokenOverride = () async {
|
||||
reloads++;
|
||||
return true; // token file content changed → reconnected
|
||||
};
|
||||
await _pumpShell(tester);
|
||||
|
||||
expect(reloads, greaterThanOrEqualTo(1));
|
||||
// The self-heal re-probe ran within the same tick (probes >
|
||||
// reloads means at least one immediate retry happened) and the
|
||||
// banner never surfaces.
|
||||
expect(probes, greaterThan(reloads));
|
||||
expect(find.textContaining('rejected the sign-in'), findsNothing);
|
||||
expect(find.textContaining("Can't reach"), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('daemonAnswers treats auth-rejected as alive', (tester) async {
|
||||
StudioShellState.debugProbeOverride =
|
||||
() async => HubProbeResult.authRejected;
|
||||
expect(await StudioShellState.daemonAnswers(), isTrue);
|
||||
|
||||
StudioShellState.debugProbeOverride =
|
||||
() async => HubProbeResult.unreachable;
|
||||
expect(await StudioShellState.daemonAnswers(), isFalse);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue