Two findings from the workspace persona review, both rated high:
* Switch race: the hub client re-pointed at a sealed area's hub
before the workspace announced the sealed context, so the 2 s
page pollers (runs, audit) could fetch and render that hub's
data without the sealed marking. Switches now run inside an
explicit switching window: opened before anything touches the
connection, announced optimistically in the identity bar
("switching…" + spinner, leave button hidden), pollers and the
shell health tick pause inside it, and pages drop replies whose
context epoch changed mid-flight. The sealed context is
announced only after the new hub answered healthy.
* Filter loss: entering a sealed area cleared the shared-hub
project filter and returning restored only the endpoint. The
filter is now parked on entry and restored on return; prefs
keep the parked value throughout, so live state and prefs agree
after the round trip (and after a mid-session relaunch).
Guard: workspace_switch_race_test pins both invariants
state-matrix-style against scripted hub + sealed-area fakes —
reconnects may only happen inside an open switch window, pollers
must stay silent inside it, and the filter must survive the round
trip. SealedAreaService gained a debugSetInstance seam so the
suite never scans a real ~/.chain.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
280 lines
8.9 KiB
Dart
280 lines
8.9 KiB
Dart
// 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<SealedArea> areas;
|
|
|
|
@override
|
|
Future<List<SealedArea>> list() async => areas;
|
|
|
|
@override
|
|
Future<String?> 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<void>? healthyGate;
|
|
bool healthyAnswer = true;
|
|
|
|
int detachedReads = 0;
|
|
|
|
@override
|
|
Future<void> 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<bool> healthy() async {
|
|
final gate = healthyGate;
|
|
if (gate != null) await gate.future;
|
|
return healthyAnswer;
|
|
}
|
|
|
|
@override
|
|
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
|
|
String project = '',
|
|
}) {
|
|
detachedReads++;
|
|
return super.listDetachedRuns(project: project);
|
|
}
|
|
}
|
|
|
|
RecordingHub _install({List<SealedArea> 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<void>();
|
|
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));
|
|
});
|
|
}
|