chain-studio/integration_test/auth_policy_shots_test.dart
flemming-it efaa089454
Some checks failed
Security / Security check (push) Failing after 1s
feat(settings): hub auth-policy panel — T4/T5 security parity in the GUI
Settings → Security now shows the hub's effective auth policy via the
new read-only AuthStatus RPC: active token validator (static / jwt-rs256
with issuer, audience, JWKS source), anonymous-access warning, per-token
cards with scope grants, env-var presence and rate limits, plus a
localized admin-denied story for non-admin tokens. Live-reloads on
endpoint change.

Also fixes a batch of fai→chain rename leftovers this panel's
verification uncovered: hub_auth_token.dart and registry_token.dart
read/wrote ~/.fai/ while the hub reads ~/.chain/ (stored registry
tokens never reached the hub), today_story_loader + tools/today used
~/.fai/today, chain_log legacy ~/.fai/logs migration removed per the
no-legacy-recognisers decision, and UI strings still advertised the
retired .fai bundle extension.

Includes 5 widget tests for the panel, an integration-test screenshot
harness (auth_policy_shots_test.dart, guide-shots style), and DE+EN
l10n. flutter analyze clean, 58 tests green.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-15 03:19:33 +02:00

187 lines
6.3 KiB
Dart

// 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<void> _pumpFrames(WidgetTester tester, [int frames = 20]) async {
for (var i = 0; i < frames; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}
Future<void> _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<void> _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);
});
}