Files
ReVoltVPN/test/session_lifecycle_regression_test.dart
esefxdzandClaude Opus 5 62e968f133
Android CI / android (push) Canceled after 0s
fix(app): stop probing the control plane while disconnected
VpnConnection._init() started a 30 s /health poll from the constructor
with no lifecycle gating, so it ran for the life of the app process -
backgrounded, and whether or not the user ever connected. The app is
excluded from its own tunnel, so those requests left in the clear. A
periodic request that fires while the user is deliberately not connected
is an avoidable fingerprint, and rule 60 already ruled probes out:
reachability is meant to be passive.

All it bought was the status dot before the first connect, and it got
that wrong in both directions - "Offline" on a transient resolver
failure against a server that was fine.

Reachability now comes from a session sync that succeeded. serverHealthy
is isConnected && hasSyncedOnce, so a green dot means a working session
rather than a check made beforehand; while disconnected the bar reads
"Ready" and claims nothing either way.

checkHealth() goes with it. Nothing calls it, and leaving a probe helper
in place invites its reuse.

Pinned in test/session_lifecycle_regression_test.dart so it cannot come
back as a tidy-up. flutter analyze clean, 10/10 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-17 21:17:17 +03:00

188 lines
8.4 KiB
Dart

import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
// Source assertions, in the style of release_verification_regression_test.dart.
// The behaviours below were all found broken on a device and fixed on
// 2026-10-17 (internal/DEVICE_TEST_2026-10-17.md). None of them can be covered
// by a Dart unit test - they live in the vendored Android runtime, and the one
// that does not needs a live tunnel - but every one of them regresses silently
// and looks like tidying when it does. Pin the shape instead of the behaviour.
const _runtime = 'local_packages/flutter_vless_android-1.1.5/android/src/main/'
'kotlin/com/github/tfox/flutter_vless/xray';
void main() {
test('Xray core recovery survives running off a bare thread', () {
final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync();
// startTimer builds a CountDownTimer, which builds a Handler. Recovery runs
// on a plain Thread, so constructing it inline threw Looper.prepare() and
// killed every restart attempt.
expect(core, contains('Handler(Looper.getMainLooper())'));
expect(core, contains('runOnMain'));
expect(
core,
contains('runOnMain { startTimerOnMain(context, token) }'),
reason: 'startTimer must not construct the CountDownTimer inline',
);
// The post must not outlive the generation that scheduled it.
expect(core, contains('if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != token) return'));
// pb.start() succeeds before the throw, so the catch has to kill the
// process or each retry strands another live core.
expect(core, contains('val orphan = xrayProcess'));
expect(
core.indexOf('xrayProcess = null'),
lessThan(core.indexOf('orphan?.destroy()')),
reason: 'clear the field before destroying, so the monitor thread\'s '
'identity guard fails and recovery is not re-entered',
);
});
test('a failed Xray core still reports why, without logging destinations', () {
final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync();
expect(core, contains('STARTUP_LOG_WINDOW_MS'));
expect(core, contains('STARTUP_LOG_MAX_LINES'));
expect(
core,
isNot(contains('reader.forEachLine { _ -> }')),
reason: 'discarding the core output leaves exit 23 undiagnosable',
);
// Rule 95: a core that has been up long enough to carry traffic must not
// have its log retained.
expect(core, contains('startupLog.clear()'));
});
test('the expiry warning has the same backstop as the deadline', () {
final service = File('$_runtime/service/XrayVPNService.kt').readAsStringSync();
// The alarm alone is inexact on purpose (no SCHEDULE_EXACT_ALARM) and Doze
// deferred it past the end of the session it was warning about.
expect(service, contains('sessionWarningRunnable'));
expect(
service,
contains('deadlineHandler.postDelayed(sessionWarningRunnable, warnDelay)'),
reason: 'without this the warning never arrives when the app is closed',
);
expect(
service,
contains('deadlineHandler.removeCallbacks(sessionWarningRunnable)'),
reason: 'a cancelled warning must drop its Handler callback too',
);
expect(service, isNot(contains('SCHEDULE_EXACT_ALARM"')));
});
test('the warning promises neither a number of minutes nor an ad', () {
final alerts = File('$_runtime/utils/RevoltAlerts.kt').readAsStringSync();
// It is accepted anywhere from WARNING_FLOOR_MS to LEAD + SLACK remaining,
// so a figure can be off by minutes; and the support bypass is still
// kDebugMode-gated (rule 87), so an ad is a dead end in release.
expect(alerts, isNot(contains('disconnects in 5 minutes')));
expect(alerts, isNot(contains('Watch an ad')));
expect(alerts, contains('Revolt VPN disconnects soon'));
});
test('the countdown notification is taken down when the tunnel stops', () {
final service = File('lib/logic/notification_service.dart').readAsStringSync();
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
final activity =
File('android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt')
.readAsStringSync();
// updateTimer re-posts an ongoing, non-dismissible notification every tick,
// so a tick after the service stops strands a frozen countdown.
expect(service, contains('cancelTimer'));
expect(activity, contains('"cancelNotification"'));
expect(timer, contains('await NotificationService.cancelTimer()'));
// Must run after the service is down, or the platform swallows the cancel.
expect(
timer.indexOf('await vpnConnection.disconnect();'),
lessThan(timer.indexOf('await NotificationService.cancelTimer()')),
);
});
test('only a user-requested disconnect hands the session back', () {
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
expect(hivemind, contains('releaseSession'));
expect(hivemind, contains("_publicUrl('/session/stop')"));
// /session/stop takes a device_id as its only proof of ownership, so it
// must never be reachable from an error path. disconnect() is called only
// by the connect button; _doDisconnect is every other ending.
final disconnectEntry = timer.indexOf('Future<void> disconnect({');
final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
final release = timer.indexOf('HivemindService.releaseSession()');
expect(release, greaterThan(disconnectEntry));
expect(
release,
lessThan(doDisconnect),
reason: 'releaseSession belongs in disconnect(), not _doDisconnect',
);
expect(
'HivemindService.releaseSession()'.allMatches(timer).length,
1,
reason: 'exactly one call site',
);
});
test('every session starts the same way - no re-attach path', () {
final mode =
File('lib/screens/settings/in_settings/connection_mode.dart')
.readAsStringSync();
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
// SOCKS5 Reconnect used to re-attach to the session already held. It was
// the only place a session began without an ad and without a nonce binding
// it, and it needed three parameters and a carve-out in rule 4 to support.
// Reconnect now ends the session and starts a fresh one, like the button.
expect(mode, contains("ad.showAd('main')"));
expect(mode, contains('vpn.connect(adNonce: adNonce)'));
expect(mode, isNot(contains('resume: true')));
expect(mode, isNot(contains('release: false')));
expect(mode, isNot(contains('resetSupportClaim')));
// The special cases that only existed for it are gone.
expect(hivemind, isNot(contains('bool resume')));
expect(vpn, isNot(contains('bool resume')));
expect(timer, isNot(contains('bool release')));
expect(timer, isNot(contains('resetSupportClaim')));
// So the nonce is now unconditional: no path accepts whatever the server
// happens to hold.
expect(
hivemind,
contains('if (serverNonce is! String || serverNonce != nonce) return null;'),
reason: 'the nonce check must not be skippable',
);
});
test('nothing probes the control plane while disconnected - rule 60', () {
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
final bar = File('lib/components/status_bar.dart').readAsStringSync();
// The runtime excludes the app from its own tunnel
// (XrayVPNService.kt addDisallowedApplication), so a periodic /health probe
// was a cleartext-SNI metronome to the API host - which resolves to the
// tunnel IP. It fed step 1 of the kill chain in KNOWN_SECURITY_ISSUES #3,
// in exchange for a green dot before the user had connected to anything.
expect(vpn, isNot(contains('_healthTimer')));
expect(vpn, isNot(contains('_checkHealth')));
expect(hivemind, isNot(contains('checkHealth')));
expect(hivemind, isNot(contains("'/health'")));
// Reachability is passive now: a session sync that succeeded, nothing else.
expect(bar, isNot(contains('serverReachable')));
expect(bar, contains('isConnected && timer.hasSyncedOnce'));
});
}