// Screenshot proof for Settings → Security → hub auth policy // (the T4/T5 parity panel): boots a hermetic hub WITH token auth // (fixture config via $CHAIN_CONFIG, secrets via env), connects // as the admin token, opens the settings dialog on the Security // category and captures the rendered policy — validator, token // cards with scope grants, the env-not-set rotation warning. // // Like guide_shots_test.dart this renders through a // RepaintBoundary, so it works headed on macOS with plain // `flutter test` and does not depend on the screen being // unlocked. Theme / locale come from the same env knobs: // // GUIDE_SHOTS_THEME=light GUIDE_SHOTS_LOCALE=en \ // flutter test integration_test/auth_policy_shots_test.dart -d macos // // Output lands in build/guide-shots/ unless GUIDE_SHOTS_OUT is set. import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_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/main.dart'; import 'package:chain_studio/widgets/widgets.dart'; import '../test/integration/hub_fixture.dart'; final GlobalKey _shotKey = GlobalKey(); const _adminSecret = 'shot-admin-secret'; /// The fixture hub's auth policy. studio-admin is the caller, /// digiscout-prod shows a fine-grained T5 scope + rate limit. /// ci-reader is appended AFTER boot (the hub refuses to start on /// an unset token_env, but AuthStatus re-reads the config live) — /// that's the rotation footgun the panel exists to surface. const _configYaml = ''' auth: tokens: - name: studio-admin token_env: CHAIN_TOKEN_STUDIO_ADMIN scopes: [admin, read, execute, install] - name: digiscout-prod token_env: CHAIN_TOKEN_DIGISCOUT scopes: [read, "execute:llm.*"] rate_limit_per_minute: 120 '''; const _ciReaderYaml = ''' - name: ci-reader token_env: CHAIN_TOKEN_CI scopes: [read] '''; Future _pumpFrames(WidgetTester tester, [int frames = 20]) async { for (var i = 0; i < frames; i++) { await tester.pump(const Duration(milliseconds: 100)); } } Future _pumpUntil( WidgetTester tester, Finder finder, { int maxFrames = 100, }) async { for (var i = 0; i < maxFrames; i++) { if (finder.evaluate().isNotEmpty) return; await tester.pump(const Duration(milliseconds: 100)); } } String get _outDir => Platform.environment['GUIDE_SHOTS_OUT'] ?? 'build/guide-shots'; ThemeModeValue get _theme => Platform.environment['GUIDE_SHOTS_THEME'] == 'light' ? ThemeModeValue.light : ThemeModeValue.dark; Locale get _locale => Locale( Platform.environment['GUIDE_SHOTS_LOCALE'] == 'en' ? 'en' : 'de', ); Future _shot(WidgetTester tester, String name) async { await tester.pump(const Duration(milliseconds: 120)); await tester.pump(const Duration(milliseconds: 120)); final boundary = _shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary; final image = await boundary.toImage(pixelRatio: 2.0); final bytes = await image.toByteData(format: ui.ImageByteFormat.png); image.dispose(); final file = File('$_outDir/$name.png'); file.parent.createSync(recursive: true); file.writeAsBytesSync(bytes!.buffer.asUint8List()); // ignore: avoid_print print('guide-shot: ${file.path}'); } void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('capture the Settings → Security auth-policy panel', (tester) async { final fixture = await HubFixture.start( skipIfBinaryMissing: false, configYaml: _configYaml, extraEnvironment: { 'CHAIN_TOKEN_STUDIO_ADMIN': _adminSecret, 'CHAIN_TOKEN_DIGISCOUT': 'shot-digiscout-secret', }, authToken: _adminSecret, ); addTearDown(fixture!.dispose); // Rotation scenario: a token added to the config after the // daemon started, its env var not exported yet → the panel // must flag it (env_set=false). File('${fixture.tempDir.path}/config.yaml') .writeAsStringSync(_configYaml + _ciReaderYaml); // Hermetic prefs: never read or write the operator's real ones. SharedPreferences.setMockInitialValues({}); await tester.pumpWidget( RepaintBoundary( key: _shotKey, child: StudioApp( initialThemeMode: _theme, initialLocale: _locale, ), ), ); await HubService.instance.reconnect( HubEndpoint(host: '127.0.0.1', port: fixture.port), authToken: _adminSecret, persist: false, ); await _pumpFrames(tester, 30); // Settings gear (sidebar) → Security category. await tester.tap(find.byIcon(Icons.settings_outlined).first); await _pumpFrames(tester); final securityTab = find.text( _locale.languageCode == 'en' ? 'Security' : 'Sicherheit', ); await _pumpUntil(tester, securityTab); await tester.tap(securityTab.first); await _pumpFrames(tester); // The policy loads from the hub asynchronously; the ci-reader // card is the last to prove the full token list arrived. await _pumpUntil(tester, find.textContaining('ci-reader')); // Bring the panel into view — it sits below the registry + // hub-token sections in the Security category's scroll area. if (find.textContaining('ci-reader').evaluate().isNotEmpty) { await tester.scrollUntilVisible( find.textContaining('ci-reader').first, 120, scrollable: find .descendant( of: find.byType(ChainSettingsDialog), matching: find.byType(Scrollable), ) .first, ); await _pumpFrames(tester, 5); } // Capture before asserting so a failing run still leaves a // diagnosable image behind. final suffix = '${_theme == ThemeModeValue.light ? 'hell' : 'dunkel'}-${_locale.languageCode}'; await _shot(tester, 'einstellungen-sicherheit-$suffix'); expect(find.textContaining('studio-admin'), findsWidgets); expect(find.textContaining('digiscout-prod'), findsWidgets); expect(find.textContaining('execute:llm.*'), findsWidgets); expect(find.textContaining('ci-reader'), findsWidgets); }); }