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>
370 lines
11 KiB
Dart
370 lines
11 KiB
Dart
// Scriptable HubService fake — the hermeticity backbone of the
|
|
// widget suites. Injected via HubService.debugSetInstance so the
|
|
// pages under test never open a real gRPC channel: test results
|
|
// must not depend on whether a hub happens to listen on the
|
|
// operator's machine (the a11y suite's old flake came exactly
|
|
// from that coupling).
|
|
//
|
|
// Behaviour model:
|
|
// * Default: a healthy, empty hub — every read succeeds with
|
|
// zero modules / flows / events / approvals / runs.
|
|
// * `failWith(error, only: {...})` scripts per-RPC failures —
|
|
// e.g. UNIMPLEMENTED for one method to simulate an older hub,
|
|
// or `failAll` for a hub that is gone entirely.
|
|
// * `FakeGrpcError` mirrors package:grpc's GrpcError shape
|
|
// (`.code` / `.message`) because production code duck-types
|
|
// on those fields (friendly_error.dart, runs classification).
|
|
//
|
|
// Any HubService member the fake does not implement lands in
|
|
// noSuchMethod, which records the name in [missing] and throws —
|
|
// so a sweep test can fail with the exact list of members that
|
|
// still need a default instead of silently rendering error states.
|
|
|
|
import 'dart:ui';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:chain_client_sdk/chain_client_sdk.dart'
|
|
show HubEndpoint, HubProbeResult, StoreSource;
|
|
import 'package:chain_studio/data/hub.dart';
|
|
|
|
/// Duck-typed stand-in for `GrpcError`: production code reads
|
|
/// `.code` (int) and `.message` (String?) off thrown objects.
|
|
class FakeGrpcError implements Exception {
|
|
final int code;
|
|
final String? message;
|
|
const FakeGrpcError(this.code, [this.message]);
|
|
|
|
@override
|
|
String toString() => 'gRPC Error (code: $code, message: $message)';
|
|
}
|
|
|
|
/// Shorthands for the codes the scenarios use.
|
|
const kUnimplemented = FakeGrpcError(12, 'method not implemented');
|
|
const kUnavailable = FakeGrpcError(14, 'connection refused');
|
|
const kDetachedOff = FakeGrpcError(
|
|
9,
|
|
'detached invocations are not enabled — set detached.enabled: true '
|
|
'in the operator config',
|
|
);
|
|
|
|
class FakeHubService extends Fake implements HubService {
|
|
/// Error thrown by every RPC-backed method when set.
|
|
Object? failAll;
|
|
|
|
/// Per-method errors, keyed by method name (e.g.
|
|
/// 'listDetachedRuns'). Wins over [failAll] absence; [failAll]
|
|
/// wins overall when both are set? No — per-method is checked
|
|
/// first so a scenario can single out one RPC.
|
|
final Map<String, Object> failures = {};
|
|
|
|
/// Members hit without an implementation (see header).
|
|
final Set<String> missing = {};
|
|
|
|
/// Scriptable bits of otherwise-default answers.
|
|
bool detachedEnabled = false;
|
|
bool probeServing = true;
|
|
|
|
/// Approvals returned by [listApprovalsRecords], filtered by the
|
|
/// requested statuses so the pending / history tabs each see the
|
|
/// right slice. Empty by default (healthy-empty inbox).
|
|
List<ApprovalRecord> approvals = const [];
|
|
|
|
void failWith(Object error, {Set<String>? only}) {
|
|
if (only == null) {
|
|
failAll = error;
|
|
} else {
|
|
for (final m in only) {
|
|
failures[m] = error;
|
|
}
|
|
}
|
|
}
|
|
|
|
T _guard<T>(String method, T Function() value) {
|
|
final err = failures[method] ?? failAll;
|
|
if (err != null) throw err;
|
|
return value();
|
|
}
|
|
|
|
Future<T> _async<T>(String method, T Function() value) async =>
|
|
_guard(method, value);
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) {
|
|
missing.add(invocation.memberName.toString());
|
|
return super.noSuchMethod(invocation);
|
|
}
|
|
|
|
// ── Connection / endpoint plumbing (never fails) ─────────────
|
|
|
|
@override
|
|
String get endpointLabel => 'http://127.0.0.1:65535';
|
|
|
|
@override
|
|
HubEndpoint get currentEndpoint =>
|
|
HubEndpoint(host: '127.0.0.1', port: 65535, secure: false);
|
|
|
|
@override
|
|
Future<void> loadPersistedEndpoint() async {}
|
|
|
|
@override
|
|
Future<void> reconnect(
|
|
HubEndpoint endpoint, {
|
|
Object? authToken = const Object(),
|
|
bool persist = true,
|
|
}) async {}
|
|
|
|
@override
|
|
Future<void> reloadAuthToken() async {}
|
|
|
|
@override
|
|
Future<bool> reloadAuthTokenIfChanged() async => false;
|
|
|
|
@override
|
|
void debugResetChannel() {}
|
|
|
|
@override
|
|
Future<ThemeModeValue> loadThemeMode() async => ThemeModeValue.system;
|
|
|
|
@override
|
|
Future<void> saveThemeMode(ThemeModeValue mode) async {}
|
|
|
|
@override
|
|
Future<Locale> loadLocale() async => const Locale('de');
|
|
|
|
@override
|
|
Future<void> saveLocale(Locale locale) async {}
|
|
|
|
@override
|
|
String? get connectedChannelName => 'local';
|
|
|
|
// ── Health ───────────────────────────────────────────────────
|
|
|
|
@override
|
|
Future<bool> healthy() =>
|
|
_async('healthy', () => probeServing && failAll == null);
|
|
|
|
@override
|
|
Future<HubProbeResult> probeHealth() async {
|
|
if (failAll != null) return HubProbeResult.unreachable;
|
|
return probeServing ? HubProbeResult.serving : HubProbeResult.notServing;
|
|
}
|
|
|
|
// ── Read paths the pages hit (healthy-empty defaults) ────────
|
|
|
|
@override
|
|
Future<String> hubVersion() => _async('hubVersion', () => '0.0.0-fake');
|
|
|
|
@override
|
|
Future<List<ModuleSummary>> listModules() =>
|
|
_async('listModules', () => const []);
|
|
|
|
@override
|
|
Future<List<CapabilityInfo>> allCapabilities() =>
|
|
_async('allCapabilities', () => const []);
|
|
|
|
@override
|
|
Future<List<StoreSource>> listStores() =>
|
|
_async('listStores', () => const []);
|
|
|
|
@override
|
|
Future<List<StoreItem>> searchStore({
|
|
String query = '',
|
|
String category = '',
|
|
String tag = '',
|
|
String status = '',
|
|
int limit = 50,
|
|
}) => _async('searchStore', () => const []);
|
|
|
|
@override
|
|
Future<List<String>> installedVersions(String moduleName) =>
|
|
_async('installedVersions', () => const []);
|
|
|
|
@override
|
|
Future<List<SavedFlow>> listFlows() => _async('listFlows', () => const []);
|
|
|
|
@override
|
|
Future<List<ProjectRef>> listProjects() =>
|
|
_async('listProjects', () => const []);
|
|
|
|
@override
|
|
Stream<AuditEvent> streamEvents({
|
|
int backfill = 0,
|
|
List<String> types = const [],
|
|
String project = '',
|
|
}) {
|
|
final err = failures['streamEvents'] ?? failAll;
|
|
if (err != null) return Stream.error(err);
|
|
return const Stream.empty();
|
|
}
|
|
|
|
@override
|
|
Future<List<AuditEvent>> recentEvents({
|
|
int limit = 50,
|
|
List<String> types = const [],
|
|
String project = '',
|
|
}) => _async('recentEvents', () => const []);
|
|
|
|
@override
|
|
Future<List<PendingApproval>> pendingApprovals({String project = ''}) =>
|
|
_async('pendingApprovals', () => const []);
|
|
|
|
@override
|
|
Future<List<ApprovalRecord>> listApprovalsRecords({
|
|
List<String> statuses = const [],
|
|
int limit = 200,
|
|
String project = '',
|
|
}) => _async(
|
|
'listApprovalsRecords',
|
|
() => statuses.isEmpty
|
|
? approvals
|
|
: approvals.where((a) => statuses.contains(a.status)).toList(),
|
|
);
|
|
|
|
@override
|
|
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
|
|
String project = '',
|
|
}) => _async('listDetachedRuns', () => (runs: const <DetachedRun>[], enabled: detachedEnabled));
|
|
|
|
@override
|
|
Future<List<Satellite>> listSatellites() =>
|
|
_async('listSatellites', () => const []);
|
|
|
|
/// Auth policy the pages read (approvals derives from it what a
|
|
/// reviewer attribution is worth). Default: the local-dev hub —
|
|
/// static validator, anonymous calls allowed. Set to `null` to
|
|
/// script a hub whose policy Studio may not read; `authStatus`
|
|
/// then fails like a PermissionDenied.
|
|
HubAuthPolicy? authPolicy = const HubAuthPolicy(
|
|
validator: 'static',
|
|
anonymousAllowed: true,
|
|
tokens: [],
|
|
);
|
|
|
|
@override
|
|
Future<HubAuthPolicy> authStatus() => _async('authStatus', () {
|
|
final policy = authPolicy;
|
|
if (policy == null) {
|
|
throw const FakeGrpcError(7, 'admin scope required');
|
|
}
|
|
return policy;
|
|
});
|
|
|
|
/// Every decision that reached the wire, in order — the guard for
|
|
/// "Studio never claims an identity it cannot prove" inspects the
|
|
/// reviewer string exactly as the hub would store it.
|
|
final List<({String id, String reviewer, String? reason})> decisions = [];
|
|
|
|
/// Reviewer + reason handed to the audit-log wipe, same purpose.
|
|
final List<({String reviewer, String reason})> clears = [];
|
|
|
|
@override
|
|
Future<({int purged, String channel})> clearEventLog({
|
|
required String reviewer,
|
|
required String reason,
|
|
}) => _async('clearEventLog', () {
|
|
clears.add((reviewer: reviewer, reason: reason));
|
|
return (purged: 0, channel: 'local');
|
|
});
|
|
|
|
@override
|
|
Future<void> approve(String id, String reviewer) => _async(
|
|
'approve',
|
|
() => decisions.add((id: id, reviewer: reviewer, reason: null)),
|
|
);
|
|
|
|
@override
|
|
Future<void> reject(String id, String reviewer, String reason) => _async(
|
|
'reject',
|
|
() => decisions.add((id: id, reviewer: reviewer, reason: reason)),
|
|
);
|
|
|
|
@override
|
|
Future<SystemAiStatus> systemAiStatus() => _async(
|
|
'systemAiStatus',
|
|
() => const SystemAiStatus(
|
|
enabled: false,
|
|
provider: '',
|
|
endpoint: '',
|
|
model: '',
|
|
privacyMode: 'off',
|
|
apiKeyEnv: '',
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<ChannelStatusSnapshot> channelStatus() => _async(
|
|
'channelStatus',
|
|
() => const ChannelStatusSnapshot(
|
|
active: 'local',
|
|
channels: [
|
|
ChannelInfo(
|
|
name: 'local',
|
|
port: 65535,
|
|
running: true,
|
|
endpoint: 'http://127.0.0.1:65535',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<List<McpClientInfo>> listMcpClients() =>
|
|
_async('listMcpClients', () => const []);
|
|
|
|
@override
|
|
Future<List<N8nEndpointInfo>> listN8nEndpoints() =>
|
|
_async('listN8nEndpoints', () => const []);
|
|
|
|
/// Scriptable update hint for the shell banner test.
|
|
UpdateStatus? updateHint;
|
|
|
|
@override
|
|
Future<UpdateStatus?> checkHubUpdate() async => updateHint;
|
|
|
|
@override
|
|
Future<bool> declareService({
|
|
required String name,
|
|
required String endpoint,
|
|
String healthPath = '',
|
|
List<String> tags = const [],
|
|
}) => _async('declareService', () => true);
|
|
|
|
@override
|
|
Future<DoctorSnapshot> doctor() => _async(
|
|
'doctor',
|
|
() => const DoctorSnapshot(
|
|
moduleCount: 0,
|
|
capabilityCount: 0,
|
|
pendingApprovals: 0,
|
|
eventChainTotal: 0,
|
|
eventChainVerified: 0,
|
|
eventChainTamperedAt: null,
|
|
services: [],
|
|
update: UpdateStatus(
|
|
channel: 'stable',
|
|
localVersion: '0.0.0-fake',
|
|
latestVersion: '0.0.0-fake',
|
|
updateAvailable: false,
|
|
manifestReachable: true,
|
|
),
|
|
paths: DaemonPathsSnapshot(
|
|
logPath: '',
|
|
dbPath: '',
|
|
modulesDir: '',
|
|
flowsDir: '',
|
|
configPath: '',
|
|
pidPath: '',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Install a [FakeHubService] for the duration of the current
|
|
/// test and restore a real instance afterwards.
|
|
FakeHubService installFakeHub() {
|
|
final fake = FakeHubService();
|
|
HubService.debugSetInstance(fake);
|
|
addTearDown(() => HubService.debugSetInstance(null));
|
|
return fake;
|
|
}
|