The hub copies the reviewer string a client sends straight into decided_by (DecideApproval, ClearEventLog); nothing on the wire ties it to the authenticated caller. Studio filled it from the OS account, so an export read like non-repudiation while being an arbitrary client claim — the legal finding of the 2026-07-26 usertest panel. The real fix is hub-side (derive decided_by from CALLER_IDENTITY); that contract is written down in docs/reviewer-identity.md and needs a hub release. Until then Studio does the one thing it can do honestly and marks its own claim as a claim, inside the record: - data/reviewer_identity.dart is the single place that produces and reads the value; wire() is idempotent, so page and HubService may both normalise. Every write path funnels through HubService, so no surface can send a bare handle. - The inbox states before the decision who will be recorded, what that attribution is worth on this hub (from AuthStatus), and the literal string that lands in decided_by. An unreadable auth policy stays unreadable — never optimistic. - Reading back: a marked value shows its plain name plus an unchecked flag; an unmarked one (legacy row, CLI decision, or a future hub-derived identity) is not classified either way. - The audit wipe seeds the same kind of marked attribution into its chain.reset marker. When the hub starts deriving the value it overwrites the field and the prefix disappears by itself — no Studio release needed. Guards: reviewer_identity_test (the value) and approvals_reviewer_identity_test (every surface that writes or renders it, against the hermetic fake hub). Visual proof for both themes via the dialog-shot harness. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
265 lines
9.9 KiB
Dart
265 lines
9.9 KiB
Dart
// Dialog capture harness — visual proof for dialogs the page-level
|
|
// guide harness doesn't reach. Renders the target dialog against
|
|
// the scriptable fake hub and writes a PNG per theme via a
|
|
// RepaintBoundary (driverless, headed macOS `flutter test`).
|
|
//
|
|
// flutter test integration_test/dialog_shots_test.dart -d macos
|
|
//
|
|
// Output: build/dialog-shots/<name>-<theme>.png (or $DIALOG_SHOTS_OUT).
|
|
|
|
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_studio_flow_editor/src/l10n.dart';
|
|
import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart';
|
|
|
|
import 'package:chain_studio/data/hub.dart'
|
|
show ApprovalRecord, StoreItem, ThemeModeValue;
|
|
import 'package:chain_studio/data/reviewer_identity.dart';
|
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
|
import 'package:chain_studio/main.dart';
|
|
import 'package:chain_studio/pages/federation.dart';
|
|
import 'package:chain_studio/widgets/chain_install_confirm.dart';
|
|
|
|
import '../test/support/fake_hub.dart';
|
|
|
|
final GlobalKey _shotKey = GlobalKey();
|
|
|
|
String get _outDir =>
|
|
Platform.environment['DIALOG_SHOTS_OUT'] ?? 'build/dialog-shots';
|
|
|
|
Future<void> _shot(WidgetTester tester, String name) async {
|
|
await tester.pump(const Duration(milliseconds: 150));
|
|
await tester.pump(const Duration(milliseconds: 150));
|
|
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('dialog-shot: ${file.path}');
|
|
}
|
|
|
|
StoreItem _storeItem(String verification) => StoreItem(
|
|
name: 'text.extract',
|
|
taglineEn: 'Extract clean text',
|
|
taglineDe: 'Reinen Text extrahieren',
|
|
descriptionEn: '',
|
|
descriptionDe: '',
|
|
category: 'text',
|
|
tags: const [],
|
|
requiresCapabilities: const [],
|
|
requiresServices: const [],
|
|
license: 'Apache-2.0',
|
|
repository: '',
|
|
bestVersion: '0.1.0',
|
|
status: 'alpha',
|
|
installed: false,
|
|
featured: false,
|
|
iconUrl: '',
|
|
screenshotUrls: const [],
|
|
docsUrl: '',
|
|
kind: 'native',
|
|
provider: '',
|
|
source: 'bundled',
|
|
installVerification: verification,
|
|
maintainers: const ['Dr. Stefan Flemming (Flemming.AI) <chain@flemming.ai>'],
|
|
);
|
|
|
|
void main() {
|
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
for (final (themeName, mode) in [
|
|
('light', ThemeMode.light),
|
|
('dark', ThemeMode.dark),
|
|
]) {
|
|
testWidgets('add-satellite dialog — $themeName', (tester) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
installFakeHub();
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
debugShowCheckedModeBanner: false,
|
|
themeMode: mode,
|
|
theme: ThemeData.light(useMaterial3: true),
|
|
darkTheme: ThemeData.dark(useMaterial3: true),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
locale: const Locale('de'),
|
|
// Wrap the whole navigator (incl. the overlay dialogs
|
|
// render into) so the capture catches the dialog, not
|
|
// just the page beneath it.
|
|
builder: (context, child) =>
|
|
RepaintBoundary(key: _shotKey, child: child),
|
|
home: const FederationPage(),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
await tester.tap(find.text('Satellit hinzufügen').first);
|
|
await tester.pumpAndSettle();
|
|
await _shot(tester, 'add-satellite-$themeName');
|
|
expect(find.textContaining('Ein Satellit ist ein weiterer Hub'),
|
|
findsOneWidget);
|
|
});
|
|
}
|
|
|
|
// Flow-list resolvability badge, both classified states side by
|
|
// side: store-resolvable missing capability (amber chip +
|
|
// Install link) vs. capability no store can install (quiet
|
|
// "nicht im Store" chip; recovery paths live in its tooltip).
|
|
for (final (themeName, mode) in [
|
|
('light', ThemeMode.light),
|
|
('dark', ThemeMode.dark),
|
|
]) {
|
|
testWidgets('flow-list badge states — $themeName', (tester) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
debugShowCheckedModeBanner: false,
|
|
themeMode: mode,
|
|
theme: ThemeData.light(useMaterial3: true),
|
|
darkTheme: ThemeData.dark(useMaterial3: true),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
locale: const Locale('de'),
|
|
builder: (context, child) =>
|
|
RepaintBoundary(key: _shotKey, child: child),
|
|
home: Scaffold(
|
|
body: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 260),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MissingModulesBadge(
|
|
installable: const ['text.extract'],
|
|
notInStore: const [],
|
|
strings:
|
|
const FlowEditorStrings(FlowEditorLocale.de),
|
|
onInstall: () {},
|
|
),
|
|
const SizedBox(height: 16),
|
|
MissingModulesBadge(
|
|
installable: const [],
|
|
notInStore: const ['acme.internal/lookup'],
|
|
strings:
|
|
const FlowEditorStrings(FlowEditorLocale.de),
|
|
onInstall: null,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
await _shot(tester, 'flow-badge-states-$themeName');
|
|
expect(find.text('1 Modul fehlt'), findsOneWidget);
|
|
expect(find.text('Installieren'), findsOneWidget);
|
|
expect(find.text('nicht im Store'), findsOneWidget);
|
|
});
|
|
}
|
|
|
|
// Trust gate with the hub-reported verification statement — the
|
|
// pinned-key (good) and policy-off (warning) variants.
|
|
for (final (name, verification) in [
|
|
('trust-gate-pinned', 'pinned-key'),
|
|
('trust-gate-unverified', 'unverified'),
|
|
]) {
|
|
testWidgets('$name — dark', (tester) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
installFakeHub();
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
debugShowCheckedModeBanner: false,
|
|
themeMode: ThemeMode.dark,
|
|
theme: ThemeData.light(useMaterial3: true),
|
|
darkTheme: ThemeData.dark(useMaterial3: true),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
locale: const Locale('de'),
|
|
builder: (context, child) =>
|
|
RepaintBoundary(key: _shotKey, child: child),
|
|
home: Scaffold(
|
|
body: Center(
|
|
child: ChainInstallConfirmDialog(
|
|
item: _storeItem(verification),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
await _shot(tester, '$name-dark');
|
|
});
|
|
}
|
|
|
|
// Reviewer identity on the approvals inbox — the strip that names
|
|
// who the audit trail will record and says, in plain words, what
|
|
// that attribution is worth on this hub. Captured in both themes
|
|
// because the "not verified" pill and the literal recorded value
|
|
// have to stay legible in each (release gate, project CLAUDE.md).
|
|
for (final (themeName, mode) in [
|
|
('light', ThemeModeValue.light),
|
|
('dark', ThemeModeValue.dark),
|
|
]) {
|
|
testWidgets('approvals reviewer identity — $themeName', (tester) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
ReviewerIdentity.debugHandle = 'stefan@studio';
|
|
addTearDown(() => ReviewerIdentity.debugHandle = null);
|
|
final fake = installFakeHub();
|
|
fake.approvals = [
|
|
ApprovalRecord(
|
|
id: 'apr-1',
|
|
flowName: 'rechnung-klassifizieren',
|
|
stepId: 'pruefen',
|
|
prompt: 'Bitte die Klassifikation dieser Rechnung bestätigen.',
|
|
payloadPreview: '{"label":"Rechnung","betrag":"128,40 EUR"}',
|
|
createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0),
|
|
expiresAt: null,
|
|
status: 'pending',
|
|
decidedAt: null,
|
|
decidedBy: '',
|
|
reason: '',
|
|
project: 'buergeramt',
|
|
flowExecution: 'run-abc123',
|
|
),
|
|
];
|
|
tester.view.physicalSize = const Size(1280, 900);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.reset);
|
|
// The real app shell, not a bare MaterialApp: the strip has to
|
|
// be judged inside the Chain theme it ships in.
|
|
await tester.pumpWidget(
|
|
RepaintBoundary(
|
|
key: _shotKey,
|
|
child: StudioApp(initialThemeMode: mode, initialLocale: Locale('de')),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals')));
|
|
for (var i = 0; i < 12; i++) {
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
}
|
|
await _shot(tester, 'approvals-reviewer-identity-$themeName');
|
|
expect(find.text('Sie entscheiden als'), findsOneWidget);
|
|
expect(
|
|
find.text('Wird gespeichert als: unverified:stefan@studio'),
|
|
findsOneWidget,
|
|
);
|
|
// Long-lived timers: unmount before the harness tears down.
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pump(const Duration(minutes: 1));
|
|
});
|
|
}
|
|
}
|