// Reviewer identity on the surfaces that write it — approvals page // and the audit-log wipe. // // Guard for the legal finding of the 2026-07-26 usertest: the hub // stores whatever reviewer string a client sends as `decided_by`, // so Studio must (a) never send a bare handle that reads like a // proven identity, (b) tell the reviewer BEFORE the decision what // the attribution is worth on this hub, and (c) present a stored // value by what it actually proves. // // Runs against the scriptable FakeHubService — never a real hub. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/data/reviewer_identity.dart'; import 'package:chain_studio/main.dart'; import 'support/fake_hub.dart'; String _allText(WidgetTester tester) { final buf = StringBuffer(); for (final w in tester.widgetList(find.byType(Text))) { buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? ''); } for (final w in tester.widgetList( find.byType(SelectableText), )) { buf.writeln(w.data ?? ''); } return buf.toString(); } ApprovalRecord _pending() => ApprovalRecord( id: 'apr-1', flowName: 'classify-and-file', stepId: 'review', prompt: 'Bitte die Klassifikation prüfen', payloadPreview: '{"label":"Rechnung"}', createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), expiresAt: null, status: 'pending', decidedAt: null, decidedBy: '', reason: '', project: 'lbs', flowExecution: 'run-abc123', ); ApprovalRecord _decided(String decidedBy) => ApprovalRecord( id: 'apr-2', flowName: 'classify-and-file', stepId: 'review', prompt: 'Bitte die Klassifikation prüfen', payloadPreview: null, createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), expiresAt: null, status: 'approved', decidedAt: DateTime.utc(2026, 7, 26, 12, 5, 0), decidedBy: decidedBy, reason: '', project: 'lbs', flowExecution: 'run-abc123', ); /// Fixed frame budget instead of `pumpAndSettle`: the page keeps a /// progress indicator alive on the hidden tab, so settling never /// completes (same reason the origin suite pumps explicitly). Future _frames(WidgetTester tester, [int count = 12]) async { for (var i = 0; i < count; i++) { await tester.pump(const Duration(milliseconds: 200)); } } /// Boot Studio on the approvals page against [fake]. Future _openApprovals(WidgetTester tester, FakeHubService fake) async { SharedPreferences.setMockInitialValues({}); tester.view.physicalSize = const Size(1280, 900); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget( const StudioApp( initialThemeMode: ThemeModeValue.dark, 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)); } } Future _teardownFrames(WidgetTester tester) async { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(minutes: 1)); } void main() { setUp(() => ReviewerIdentity.debugHandle = 'stefan@studio'); tearDown(() => ReviewerIdentity.debugHandle = null); testWidgets('the inbox names who will be recorded, flags the claim as ' 'unchecked and shows the literal stored value', (tester) async { final fake = installFakeHub(); fake.approvals = [_pending()]; await _openApprovals(tester, fake); final text = _allText(tester); expect(text, contains('Sie entscheiden als')); expect(text, contains('stefan@studio')); expect(text, contains('nicht überprüft')); // No surprise later: the exact string that lands in decided_by. expect(text, contains('unverified:stefan@studio')); await _teardownFrames(tester); }); testWidgets('an anonymous hub says a decision cannot be pinned on ' 'anyone', (tester) async { final fake = installFakeHub(); fake.approvals = [_pending()]; // Default fake policy: static validator, anonymous allowed. await _openApprovals(tester, fake); expect(_allText(tester), contains('ohne Zugangsdaten')); await _teardownFrames(tester); }); testWidgets('an auth-enabled hub says the access is checked but the ' 'name is not', (tester) async { final fake = installFakeHub(); fake.approvals = [_pending()]; fake.authPolicy = const HubAuthPolicy( validator: 'static', anonymousAllowed: false, tokens: [], ); await _openApprovals(tester, fake); expect(_allText(tester), contains('prüft Ihren Zugang')); await _teardownFrames(tester); }); testWidgets('an unreadable auth policy stays honest instead of ' 'guessing', (tester) async { final fake = installFakeHub(); fake.approvals = [_pending()]; // AuthStatus is admin-scoped: a plain reviewer token is denied. fake.authPolicy = null; await _openApprovals(tester, fake); expect(_allText(tester), contains('Admin-Rechte')); // Still flagged — not knowing never upgrades the attribution. expect(_allText(tester), contains('nicht überprüft')); await _teardownFrames(tester); }); testWidgets('approving sends the marked attribution, never a bare ' 'handle', (tester) async { final fake = installFakeHub(); fake.approvals = [_pending()]; await _openApprovals(tester, fake); await tester.tap(find.widgetWithText(FilledButton, 'Freigeben').first); for (var i = 0; i < 8; i++) { await tester.pump(const Duration(milliseconds: 200)); } expect(fake.decisions, hasLength(1)); expect(fake.decisions.single.id, 'apr-1'); expect(fake.decisions.single.reviewer, 'unverified:stefan@studio'); expect( fake.decisions.single.reviewer, startsWith(kUnverifiedReviewerPrefix), reason: 'the decide path must not claim an identity Studio ' 'cannot prove', ); await _teardownFrames(tester); }); testWidgets('rejecting sends the marked attribution too', (tester) async { final fake = installFakeHub(); fake.approvals = [_pending()]; await _openApprovals(tester, fake); await tester.tap(find.widgetWithText(OutlinedButton, 'Ablehnen').first); await _frames(tester); await tester.enterText(find.byType(TextField).last, 'Beleg fehlt'); await _frames(tester); await tester.tap(find.widgetWithText(FilledButton, 'Ablehnen').last); for (var i = 0; i < 8; i++) { await tester.pump(const Duration(milliseconds: 200)); } expect(fake.decisions, hasLength(1)); expect(fake.decisions.single.reviewer, 'unverified:stefan@studio'); expect(fake.decisions.single.reason, 'Beleg fehlt'); await _teardownFrames(tester); }); testWidgets('the history shows a marked value by its name and keeps ' 'the "unchecked" flag', (tester) async { final fake = installFakeHub(); fake.approvals = [_decided('unverified:anna@ops')]; await _openApprovals(tester, fake); await tester.tap(find.text('Verlauf')); await _frames(tester); final text = _allText(tester); expect(text, contains('anna@ops')); // The raw marker is not what a human should read in the list. expect(text, isNot(contains('unverified:anna@ops'))); // The detail dialog spells the trust level out in words. await tester.tap(find.textContaining('classify-and-file').last); await _frames(tester); expect(_allText(tester), contains('anna@ops (nicht überprüft)')); await _teardownFrames(tester); }); testWidgets('the audit-log wipe records the same kind of marked ' 'attribution in the chain.reset marker', (tester) async { // Same bug class as approvals: the reset marker names a // "Prüfer" the hub never checked, and auditors read exactly // that marker to explain a chain discontinuity. final fake = installFakeHub(); SharedPreferences.setMockInitialValues({}); tester.view.physicalSize = const Size(1280, 900); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget( const StudioApp( initialThemeMode: ThemeModeValue.dark, initialLocale: Locale('de'), ), ); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byKey(const ValueKey('sidebar-item-audit'))); await _frames(tester); await tester.tap(find.byTooltip('Weitere Aktionen')); await _frames(tester); // Tap the entry, not its label: the overflow menu's labels are // wider than the 256px menu, so the label's centre lies outside // the hit box (a separate, unrelated layout defect on that // menu). The entry itself is what the operator hits. await tester.tap( find.ancestor( of: find.textContaining('Entwicklungs-Reset'), matching: find.byType(PopupMenuItem), ), ); await _frames(tester); // The field says what will be stored, before it is stored. expect(_allText(tester), contains('unverified:')); await tester.enterText(find.byType(TextField).last, 'Testdaten verworfen'); await _frames(tester); await tester.tap(find.widgetWithText(FilledButton, 'Log löschen')); await _frames(tester); expect(fake.clears, hasLength(1)); expect(fake.clears.single.reviewer, 'unverified:stefan@studio'); expect(fake.clears.single.reason, 'Testdaten verworfen'); await _teardownFrames(tester); }); testWidgets('a legacy / CLI value without a marker is shown as-is and ' 'never labelled either way', (tester) async { final fake = installFakeHub(); fake.approvals = [_decided('ops-lead')]; await _openApprovals(tester, fake); await tester.tap(find.text('Verlauf')); await _frames(tester); await tester.tap(find.textContaining('classify-and-file').last); await _frames(tester); final text = _allText(tester); expect(text, contains('ops-lead')); expect( text, isNot(contains('ops-lead (nicht überprüft)')), reason: 'Studio does not know where an unmarked value came ' 'from and must not classify it', ); await _teardownFrames(tester); }); }