chain-studio/test/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

105 lines
4 KiB
Dart

// Reviewer identity — the attribution Studio writes into the audit
// trail must never look better than it is.
//
// Background (usertest 2026-07-26, legal persona): the hub copies
// the client's `reviewer` string into `decided_by` unchecked, so a
// bare "stefan@studio" in an export reads like a proven identity
// while being an arbitrary client claim. Until the hub derives the
// value from the authenticated caller (contract:
// docs/reviewer-identity.md), every value Studio sends carries the
// `unverified:` marker — and everything Studio *reads* is presented
// by what it actually proves.
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/data/reviewer_identity.dart';
void main() {
setUp(() => ReviewerIdentity.debugHandle = 'stefan@studio');
tearDown(() => ReviewerIdentity.debugHandle = null);
group('what Studio sends', () {
test('every reviewer string leaves Studio marked as a claim', () {
expect(ReviewerIdentity.wire(), 'unverified:stefan@studio');
expect(ReviewerIdentity.wire('anna@ops'), 'unverified:anna@ops');
});
test('marking is idempotent — a value that already carries the '
'marker is not wrapped twice', () {
final once = ReviewerIdentity.wire('anna@ops');
expect(ReviewerIdentity.wire(once), once);
// The page computes the wire value for display AND HubService
// normalises again as a backstop; that must stay harmless.
expect(ReviewerIdentity.wire(ReviewerIdentity.wire(once)), once);
});
test('a blank handle falls back to the local one instead of '
'sending an empty attribution', () {
expect(ReviewerIdentity.wire(' '), 'unverified:stefan@studio');
expect(ReviewerIdentity.wire(''), 'unverified:stefan@studio');
});
test('surrounding whitespace never reaches the record', () {
expect(ReviewerIdentity.wire(' anna@ops '), 'unverified:anna@ops');
});
test('the local handle is a label, not an identity — but always '
'present', () {
ReviewerIdentity.debugHandle = null;
expect(ReviewerIdentity.localHandle, isNotEmpty);
expect(
ReviewerIdentity.wire(),
startsWith(kUnverifiedReviewerPrefix),
reason: 'no code path may send a bare handle',
);
});
});
group('what Studio reads back', () {
test('a marked value is shown by its name and flagged as a claim', () {
final parsed = ReviewerIdentity.parse('unverified:anna@ops');
expect(parsed.handle, 'anna@ops');
expect(parsed.trust, RecordedReviewerTrust.selfDeclared);
expect(parsed.isSelfDeclared, isTrue);
});
test('an unmarked value (legacy row, CLI decision) is never '
'presented as checked', () {
final parsed = ReviewerIdentity.parse('stefan@studio');
expect(parsed.handle, 'stefan@studio');
expect(parsed.trust, RecordedReviewerTrust.unknown);
expect(
parsed.isSelfDeclared,
isFalse,
reason: 'unknown provenance must not be labelled either way',
);
});
test('a marker with nothing behind it keeps the marker visible '
'rather than rendering an empty reviewer', () {
final parsed = ReviewerIdentity.parse('unverified:');
expect(parsed.handle, 'unverified:');
expect(parsed.isSelfDeclared, isTrue);
});
test('round-trips what Studio wrote', () {
final parsed = ReviewerIdentity.parse(ReviewerIdentity.wire());
expect(parsed.handle, 'stefan@studio');
expect(parsed.isSelfDeclared, isTrue);
});
});
group('assurance level per hub policy', () {
test('anonymous hub — nobody can be tied to a decision', () {
expect(reviewerAssuranceFor(true), ReviewerAssurance.anonymousHub);
});
test('auth-enabled hub — the access is checked, the name is not', () {
expect(reviewerAssuranceFor(false), ReviewerAssurance.accessControlled);
});
test('unreadable policy stays unknown — never optimistic', () {
expect(reviewerAssuranceFor(null), ReviewerAssurance.unknown);
});
});
}