Some checks failed
Security / Security check (push) Failing after 1s
Settings → Security now shows the hub's effective auth policy via the new read-only AuthStatus RPC: active token validator (static / jwt-rs256 with issuer, audience, JWKS source), anonymous-access warning, per-token cards with scope grants, env-var presence and rate limits, plus a localized admin-denied story for non-admin tokens. Live-reloads on endpoint change. Also fixes a batch of fai→chain rename leftovers this panel's verification uncovered: hub_auth_token.dart and registry_token.dart read/wrote ~/.fai/ while the hub reads ~/.chain/ (stored registry tokens never reached the hub), today_story_loader + tools/today used ~/.fai/today, chain_log legacy ~/.fai/logs migration removed per the no-legacy-recognisers decision, and UI strings still advertised the retired .fai bundle extension. Includes 5 widget tests for the panel, an integration-test screenshot harness (auth_policy_shots_test.dart, guide-shots style), and DE+EN l10n. flutter analyze clean, 58 tests green. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
221 lines
7.8 KiB
Dart
221 lines
7.8 KiB
Dart
// Hub fixture for integration-level tests.
|
|
//
|
|
// Spawns a real `chain serve` subprocess on a free port in a
|
|
// temporary directory, waits for its gRPC endpoint to answer
|
|
// `Healthy`, and exposes the port + a teardown so test files
|
|
// can talk to a clean Hub without setting up an isolate-side
|
|
// gRPC server in-process.
|
|
//
|
|
// Cost / scope notes:
|
|
//
|
|
// * The `chain` binary must be on the operator's PATH or at
|
|
// `target/release/chain` relative to the platform repo. We
|
|
// resolve those two paths in order; if neither exists, the
|
|
// fixture skips the suite with a clear message rather than
|
|
// failing — these tests are opt-in.
|
|
// * The hub is started with `--quiet` (no log spam) against a
|
|
// temp dir, so the operator's real `~/.chain/` stays untouched.
|
|
// * Cleanup runs `process.kill(SIGTERM)` then `process.kill()`
|
|
// after a grace period. The temp dir is removed.
|
|
// * Each fixture is independent — call `HubFixture.start()` in
|
|
// `setUp`, `fixture.dispose()` in `tearDown`. Don't share
|
|
// between tests; the few seconds of startup beat debugging
|
|
// state-bleed.
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:chain_client_sdk/chain_client_sdk.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
class HubFixture {
|
|
/// The hub subprocess. Kept private so callers go through
|
|
/// [dispose] for shutdown.
|
|
final Process _process;
|
|
|
|
/// gRPC port the hub is listening on. Free port chosen by
|
|
/// the OS at startup time.
|
|
final int port;
|
|
|
|
/// Temp directory the hub was started against. Wiped in
|
|
/// [dispose] so tests leave no state behind on disk.
|
|
final Directory tempDir;
|
|
|
|
/// Pre-connected SDK client. Reuse across the test's
|
|
/// assertions — gRPC channel creation is cheap but
|
|
/// non-zero, and the tests don't benefit from churning it.
|
|
final HubClient client;
|
|
|
|
HubFixture._({
|
|
required Process process,
|
|
required this.port,
|
|
required this.tempDir,
|
|
required this.client,
|
|
}) : _process = process;
|
|
|
|
/// Boots a fresh hub against a temp dir and waits up to
|
|
/// `readyTimeout` for it to answer Healthy. Use
|
|
/// [skipIfBinaryMissing] = true (default) to let tests
|
|
/// `markSkipped` instead of failing when the binary isn't
|
|
/// present — that's the desired behaviour in CI environments
|
|
/// that don't build the binary first.
|
|
///
|
|
/// [configYaml], when given, is written to `<tempDir>/config.yaml`
|
|
/// and handed to the hub via `$CHAIN_CONFIG` — tests can exercise
|
|
/// operator policy (e.g. `auth.tokens`) without touching the real
|
|
/// `~/.chain/config.yaml`. Token *values* go through
|
|
/// [extraEnvironment]; pass the matching [authToken] so the
|
|
/// readiness poll (and [client]) authenticate against the hub.
|
|
static Future<HubFixture?> start({
|
|
// A cold hub spends its first 30s building the curated-
|
|
// model database + initialising SQLite. 60s gives that
|
|
// honest headroom without making a hung hub look like
|
|
// a slow one.
|
|
Duration readyTimeout = const Duration(seconds: 60),
|
|
bool skipIfBinaryMissing = true,
|
|
String? configYaml,
|
|
Map<String, String> extraEnvironment = const {},
|
|
String? authToken,
|
|
}) async {
|
|
final binary = await _resolveBinary();
|
|
if (binary == null) {
|
|
if (skipIfBinaryMissing) {
|
|
markTestSkipped(
|
|
'Hub fixture skipped: no `chain` binary found via \$CHAIN_BIN, '
|
|
'PATH, or ../fai_chain/target/{release,debug}/chain. Build it '
|
|
'with `cargo build` in the platform repo and re-run.',
|
|
);
|
|
return null;
|
|
}
|
|
throw StateError('chain binary not found');
|
|
}
|
|
|
|
final tempDir = await Directory.systemTemp.createTemp('chain_studio_test_');
|
|
final port = await _pickFreePort();
|
|
final addr = '127.0.0.1:$port';
|
|
|
|
String? configPath;
|
|
if (configYaml != null) {
|
|
configPath = '${tempDir.path}/config.yaml';
|
|
File(configPath).writeAsStringSync(configYaml);
|
|
}
|
|
|
|
// Run as a child process. `serve --bind` lets us avoid the
|
|
// default 50051 (and any local daemon the developer happens
|
|
// to be running). CHAIN_DATA_DIR keeps the temp footprint
|
|
// contained — no state leaks into ~/.chain.
|
|
final process = await Process.start(
|
|
binary,
|
|
['serve', '--bind', addr],
|
|
environment: {
|
|
...Platform.environment,
|
|
'CHAIN_DATA_DIR': tempDir.path,
|
|
'CHAIN_MODULES_DIR': '${tempDir.path}/modules',
|
|
'CHAIN_CONFIG': ?configPath,
|
|
...extraEnvironment,
|
|
// Suppress info logs without a CLI flag — the binary
|
|
// respects RUST_LOG for tracing-subscriber.
|
|
'RUST_LOG': 'warn',
|
|
},
|
|
);
|
|
|
|
// Drain stdout/stderr to keep the OS pipe buffers from
|
|
// blocking the hub. Test failures still surface the last
|
|
// ~64 KiB of output via the dispose path.
|
|
process.stdout.transform(SystemEncoding().decoder).listen(_noop);
|
|
process.stderr.transform(SystemEncoding().decoder).listen(_noop);
|
|
|
|
// Poll the hub until it's Healthy. Bound by readyTimeout —
|
|
// a hung hub is a test failure, not a hang.
|
|
final client = HubClient(
|
|
endpoint: HubEndpoint(host: '127.0.0.1', port: port),
|
|
authToken: authToken,
|
|
);
|
|
final deadline = DateTime.now().add(readyTimeout);
|
|
while (DateTime.now().isBefore(deadline)) {
|
|
try {
|
|
final ok = await client.healthy();
|
|
if (ok) {
|
|
return HubFixture._(
|
|
process: process,
|
|
port: port,
|
|
tempDir: tempDir,
|
|
client: client,
|
|
);
|
|
}
|
|
} catch (_) {
|
|
// Hub still booting; try again.
|
|
}
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
}
|
|
|
|
// Boot didn't complete in time. Tear down + fail with a
|
|
// useful message.
|
|
process.kill(ProcessSignal.sigterm);
|
|
await tempDir.delete(recursive: true);
|
|
throw StateError(
|
|
'Hub did not become healthy within $readyTimeout. '
|
|
'Check `chain serve` works manually with CHAIN_DATA_DIR=$tempDir.',
|
|
);
|
|
}
|
|
|
|
/// Shuts down the hub and removes the temp dir. Idempotent
|
|
/// so a teardown that runs after a test-failure exit still
|
|
/// cleans up.
|
|
Future<void> dispose() async {
|
|
await client.close();
|
|
_process.kill(ProcessSignal.sigterm);
|
|
// Give the hub up to 3 seconds to flush its event log.
|
|
try {
|
|
await _process.exitCode.timeout(const Duration(seconds: 3));
|
|
} on TimeoutException {
|
|
_process.kill(ProcessSignal.sigkill);
|
|
}
|
|
if (await tempDir.exists()) {
|
|
await tempDir.delete(recursive: true);
|
|
}
|
|
}
|
|
|
|
/// Resolved hub binary path, for tests that also drive the CLI
|
|
/// against the fixture's data dir (e.g. seeding projects).
|
|
static Future<String?> binaryPath() => _resolveBinary();
|
|
|
|
/// Look for the hub binary: $CHAIN_BIN override first, then
|
|
/// `chain` on PATH (production-ish), then the release/debug
|
|
/// build outputs relative to the platform checkout (developer
|
|
/// flow, repo dir `fai_chain`). Returns null when nothing
|
|
/// resolves.
|
|
static Future<String?> _resolveBinary() async {
|
|
final fromEnv = Platform.environment['CHAIN_BIN'];
|
|
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
|
|
return fromEnv;
|
|
}
|
|
|
|
final pathResult = await Process.run(
|
|
Platform.isWindows ? 'where' : 'which',
|
|
[Platform.isWindows ? 'chain.exe' : 'chain'],
|
|
);
|
|
if (pathResult.exitCode == 0) {
|
|
final s = (pathResult.stdout as String).trim();
|
|
if (s.isNotEmpty) return s.split('\n').first.trim();
|
|
}
|
|
|
|
final exe = Platform.isWindows ? 'chain.exe' : 'chain';
|
|
for (final dir in ['release', 'debug']) {
|
|
final f = File('../fai_chain/target/$dir/$exe');
|
|
if (await f.exists()) {
|
|
return f.absolute.path;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static Future<int> _pickFreePort() async {
|
|
final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
|
final port = s.port;
|
|
await s.close();
|
|
return port;
|
|
}
|
|
}
|
|
|
|
void _noop(Object _) {}
|