chain-studio/test/approvals_reviewer_identity_test.dart
flemming-it ebc668d28d feat(approvals,audit): record the reviewer as the unchecked claim it is
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>
2026-08-03 23:50:34 +02:00

288 lines
10 KiB
Dart

// 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<Text>(find.byType(Text))) {
buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? '');
}
for (final w in tester.widgetList<SelectableText>(
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<void> _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<void> _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<void> _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<String>),
),
);
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);
});
}