// Wächter for the sealed-switch privacy race (persona review // 2026-08-27, HOCH): the hub client must never point at a different // hub than the context the UI announces. The bug class: Studio's // client re-pointed at a sealed hub BEFORE the workspace announced // the sealed context, so a 2 s page poller could fetch and render // confidential data without the sealed marking. // // Invariants pinned here: // 1. reconnect() happens only inside an open switch window // (Workspace.switching == true), and the sealed context is // announced only AFTER the connection settled — never before, // never skipped. // 2. While the window is open, the polling pages issue no reads — // sealed data can never render unmarked (RunsPage as the // poll-pattern representative; Audit uses the same guard). // 3. The shared-hub project filter survives a sealed round trip // (park + restore — the second HOCH bug), and prefs agree with // the live state afterwards. // 4. A failed switch rolls back to the shared hub without ever // announcing the sealed context and closes the window. // // Hermetic: scripted fakes for HubService AND SealedAreaService — // the suite never scans the operator's real ~/.chain. import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/data/sealed_areas.dart'; import 'package:chain_studio/data/workspace.dart'; import 'package:chain_studio/l10n/app_localizations.dart'; import 'package:chain_studio/pages/runs.dart'; import 'support/fake_hub.dart'; const _areaA = SealedArea( slug: 'area-a', name: 'Area A', color: '#e0a458', port: 51001, running: true, ); const _areaB = SealedArea( slug: 'area-b', name: 'Area B', color: '#7cb47c', port: 51002, running: true, ); class FakeSealedAreas extends SealedAreaService { FakeSealedAreas(this.areas) : super.forTest(); List areas; @override Future> list() async => areas; @override Future boundEndpoint(String slug) async => null; } class RecordingHub extends FakeHubService { /// Every reconnect with the workspace state AT CALL TIME — the /// race guard reads exactly these two flags. final List<({int port, bool switchingAtCall, bool sealedAnnounced})> reconnects = []; /// When set, healthy() blocks until completed — holds the switch /// window open so the poller-pause test can tick timers inside it. Completer? healthyGate; bool healthyAnswer = true; int detachedReads = 0; @override Future reconnect( HubEndpoint endpoint, { Object? authToken = const Object(), bool persist = true, }) async { reconnects.add(( port: endpoint.port, switchingAtCall: Workspace.instance.switching, sealedAnnounced: Workspace.instance.inSealedArea, )); } @override Future healthy() async { final gate = healthyGate; if (gate != null) await gate.future; return healthyAnswer; } @override Future<({List runs, bool enabled})> listDetachedRuns({ String project = '', }) { detachedReads++; return super.listDetachedRuns(project: project); } } RecordingHub _install({List areas = const [_areaA, _areaB]}) { SharedPreferences.setMockInitialValues({}); final hub = RecordingHub(); HubService.debugSetInstance(hub); SealedAreaService.debugSetInstance(FakeSealedAreas(areas)); addTearDown(() { HubService.debugSetInstance(null); SealedAreaService.debugSetInstance(null); Workspace.instance.debugSeed(projects: const [], active: ''); }); Workspace.instance.debugSeed( projects: const [], active: '', sealed: areas, ); return hub; } void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('the switch window opens before the client re-points and the ' 'sealed context is announced only after it settled', () async { final hub = _install(); final ws = Workspace.instance; final r = await ws.switchToSealed(_areaA); expect(r.ok, isTrue); expect(hub.reconnects, hasLength(1)); final call = hub.reconnects.single; expect(call.port, _areaA.port); expect( call.switchingAtCall, isTrue, reason: 'the client re-pointed OUTSIDE a switch window — pollers ' 'were not paused and could render sealed data unmarked', ); expect( call.sealedAnnounced, isFalse, reason: 'the sealed context must be announced only after the ' 'connection settled, never before reconnect', ); expect(ws.switching, isFalse); expect(ws.inSealedArea, isTrue); expect(ws.activeSealed, _areaA); }); test('leaving a sealed area also runs inside a switch window and ' 'keeps the sealed marking until the connection settled', () async { final hub = _install(); final ws = Workspace.instance; await ws.switchToSealed(_areaA); hub.reconnects.clear(); await ws.switchToShared(); expect(hub.reconnects, hasLength(1)); final call = hub.reconnects.single; expect(call.switchingAtCall, isTrue); expect( call.sealedAnnounced, isTrue, reason: 'while returning, the data still on screen is the sealed ' "area's — its marking must not drop before the switch settled", ); expect(ws.switching, isFalse); expect(ws.inSealedArea, isFalse); }); test('the project filter survives a sealed round trip, live and in ' 'prefs', () async { _install(); final ws = Workspace.instance; await ws.setActive('proj-a'); await ws.switchToSealed(_areaA); expect(ws.activeSlug, isEmpty, reason: 'the shared-hub filter does not apply inside an area'); await ws.switchToShared(); expect(ws.activeSlug, 'proj-a'); final prefs = await SharedPreferences.getInstance(); expect(prefs.getString('workspace.active'), 'proj-a', reason: 'live state and prefs must agree after the round trip'); }); test('area-to-area switching keeps the originally parked filter', () async { _install(); final ws = Workspace.instance; await ws.setActive('proj-a'); await ws.switchToSealed(_areaA); await ws.switchToSealed(_areaB); await ws.switchToShared(); expect(ws.activeSlug, 'proj-a'); }); test('a failed switch rolls back without announcing the sealed ' 'context and closes the window', () async { final hub = _install(); hub.healthyAnswer = false; final ws = Workspace.instance; await ws.setActive('proj-a'); hub.reconnects.clear(); final r = await ws.switchToSealed(_areaA); expect(r.ok, isFalse); expect(r.error, contains(_areaA.name)); expect(ws.inSealedArea, isFalse); expect(ws.switching, isFalse); expect(ws.activeSlug, 'proj-a', reason: 'filter untouched'); // Attempt at the area, then the rollback to the shared hub — // and the sealed context was never announced in between. expect(hub.reconnects, hasLength(2)); expect(hub.reconnects.last.port, isNot(_areaA.port)); expect(hub.reconnects.map((c) => c.sealedAnnounced), everyElement(isFalse)); }); testWidgets('the runs poller is paused while the switch window is ' 'open and resumes on the settled context', (tester) async { final hub = _install(); final ws = Workspace.instance; await tester.pumpWidget( const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, locale: Locale('de'), home: RunsPage(), ), ); await tester.pump(const Duration(milliseconds: 100)); final beforeWindow = hub.detachedReads; expect(beforeWindow, greaterThan(0), reason: 'sanity: the page polls at all'); // Hold the switch in flight and tick the 2 s poller inside it. hub.healthyGate = Completer(); final switching = ws.switchToSealed(_areaA); await tester.pump(const Duration(milliseconds: 10)); final inWindow = hub.detachedReads; await tester.pump(const Duration(seconds: 2)); await tester.pump(const Duration(seconds: 2)); expect( hub.detachedReads, inWindow, reason: 'a poll fired inside the switch window — it could have ' 'rendered the sealed hub\'s data without the sealed marking', ); hub.healthyGate!.complete(); await switching; await tester.pump(const Duration(milliseconds: 100)); expect(ws.inSealedArea, isTrue); expect( hub.detachedReads, greaterThan(inWindow), reason: 'the end-of-switch notify must refresh the page against ' 'the settled context', ); // Tear down the page so its periodic timer dies with it. await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(minutes: 1)); }); }