ca3a8b6298
The deep dive (constellation-docs research/tsnet-deafness-deep-dive.md) established the embedded node goes deaf because a zombie home-DERP connection is only detected by the 120 s read deadline and the Rebind/ ReSTUN repair machinery runs solely on netmon events an embedding never receives. TailscaleRecoverPaths() runs SetNetworkUp + Rebind + ReSTUN on the running node (3 s DERP probe bound; holds the node lock because Conn.Rebind has no closed-conn fence upstream, unlike the LocalClient paths), and the Swift layer now feeds real NWPathMonitor transitions into netmon exactly as the official Apple client does, so the repair machinery finally runs on network changes. The TailscalePingPeer docs no longer claim a disco ping re-registers DERP: it is a detector, not a cure. Opt-in debugNetLogging start flag (envknob; compiled out of GOOS=ios by upstream, effective on desktop/Android bench builds). Go suite 29/29 under -race, Dart 13/13, both xcframework slices export the new symbols. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
269 lines
8.1 KiB
Dart
269 lines
8.1 KiB
Dart
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:tsnet_flutter/tsnet_flutter.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
test('status exposes stable node ID and chooses IPv4', () {
|
|
final status = TailscaleStatus.fromJson({
|
|
'BackendState': 'Running',
|
|
'Self': {
|
|
'ID': 'node-stable-id',
|
|
'TailscaleIPs': ['fd7a:115c:a1e0::1', '100.100.10.20'],
|
|
},
|
|
});
|
|
|
|
expect(status.isRunning, isTrue);
|
|
expect(status.selfNodeId, 'node-stable-id');
|
|
expect(status.selfIP, '100.100.10.20');
|
|
});
|
|
|
|
test('status does not expose IPv6 as the enrollment address', () {
|
|
final status = TailscaleStatus.fromJson({
|
|
'BackendState': 'Running',
|
|
'Self': {
|
|
'ID': 'node-stable-id',
|
|
'TailscaleIPs': ['fd7a:115c:a1e0::1'],
|
|
},
|
|
});
|
|
|
|
expect(status.selfIP, isNull);
|
|
});
|
|
|
|
group('proxy handles over the method channel', () {
|
|
const channel = MethodChannel('tsnet_flutter');
|
|
late TsnetFlutter tsnet;
|
|
late List<MethodCall> calls;
|
|
late int nextHandle;
|
|
|
|
setUp(() {
|
|
tsnet = TsnetFlutter();
|
|
calls = [];
|
|
nextHandle = 0;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, (call) async {
|
|
calls.add(call);
|
|
switch (call.method) {
|
|
case 'startProxyHandle':
|
|
nextHandle++;
|
|
return {'handle': nextHandle, 'localPort': 50000 + nextHandle};
|
|
case 'stopProxyHandle':
|
|
return null;
|
|
case 'startProxy':
|
|
return 61000;
|
|
case 'stopProxy':
|
|
return null;
|
|
}
|
|
throw MissingPluginException(call.method);
|
|
});
|
|
});
|
|
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, null);
|
|
});
|
|
|
|
test(
|
|
'startProxyHandle passes the remote and returns handle plus port',
|
|
() async {
|
|
final proxy = await tsnet.startProxyHandle('100.64.0.5', port: 5050);
|
|
|
|
expect(proxy.id, 1);
|
|
expect(proxy.localPort, 50001);
|
|
expect(calls, hasLength(1));
|
|
expect(calls.single.method, 'startProxyHandle');
|
|
expect(calls.single.arguments, {'ip': '100.64.0.5', 'port': 5050});
|
|
},
|
|
);
|
|
|
|
test('concurrent starts return independent handles', () async {
|
|
final a = await tsnet.startProxyHandle('100.64.0.5');
|
|
final b = await tsnet.startProxyHandle('100.64.0.6');
|
|
final c = await tsnet.startProxyHandle('100.64.0.7');
|
|
|
|
expect({a.id, b.id, c.id}, hasLength(3));
|
|
expect({a.localPort, b.localPort, c.localPort}, hasLength(3));
|
|
expect(calls.map((c) => c.method), everyElement('startProxyHandle'));
|
|
});
|
|
|
|
test('stopProxyHandle stops exactly the given handle', () async {
|
|
final a = await tsnet.startProxyHandle('100.64.0.5');
|
|
final b = await tsnet.startProxyHandle('100.64.0.6');
|
|
|
|
await tsnet.stopProxyHandle(a);
|
|
|
|
final stops = calls.where((c) => c.method == 'stopProxyHandle');
|
|
expect(stops, hasLength(1));
|
|
expect(stops.single.arguments, {'handle': a.id});
|
|
expect(b.id, isNot(a.id));
|
|
});
|
|
|
|
test('legacy startProxy and stopProxy keep their wire contract', () async {
|
|
final port = await tsnet.startProxy('100.64.0.5', port: 5050);
|
|
await tsnet.stopProxy();
|
|
|
|
expect(port, 61000);
|
|
expect(calls, hasLength(2));
|
|
expect(calls[0].method, 'startProxy');
|
|
expect(calls[0].arguments, {'ip': '100.64.0.5', 'port': 5050});
|
|
expect(calls[1].method, 'stopProxy');
|
|
expect(calls[1].arguments, isNull);
|
|
});
|
|
|
|
test('proxy handles compare by value', () {
|
|
const a = TsnetProxyHandle(id: 3, localPort: 50003);
|
|
const b = TsnetProxyHandle(id: 3, localPort: 50003);
|
|
const c = TsnetProxyHandle(id: 4, localPort: 50004);
|
|
|
|
expect(a, b);
|
|
expect(a.hashCode, b.hashCode);
|
|
expect(a, isNot(c));
|
|
});
|
|
});
|
|
|
|
group('pingPeer over the method channel', () {
|
|
const channel = MethodChannel('tsnet_flutter');
|
|
late TsnetFlutter tsnet;
|
|
late List<MethodCall> calls;
|
|
Object? pingError;
|
|
|
|
setUp(() {
|
|
tsnet = TsnetFlutter();
|
|
calls = [];
|
|
pingError = null;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, (call) async {
|
|
calls.add(call);
|
|
if (call.method == 'pingPeer') {
|
|
if (pingError != null) throw pingError!;
|
|
return null;
|
|
}
|
|
throw MissingPluginException(call.method);
|
|
});
|
|
});
|
|
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, null);
|
|
});
|
|
|
|
test('passes the peer IP and reports success as true', () async {
|
|
final ok = await tsnet.pingPeer('100.64.0.5');
|
|
|
|
expect(ok, isTrue);
|
|
expect(calls, hasLength(1));
|
|
expect(calls.single.method, 'pingPeer');
|
|
expect(calls.single.arguments, {'ip': '100.64.0.5'});
|
|
});
|
|
|
|
test('a native error (node not up, timeout) becomes false', () async {
|
|
pingError = PlatformException(
|
|
code: 'PING_FAILED',
|
|
message: 'not started, call TailscaleStart() first',
|
|
);
|
|
|
|
final ok = await tsnet.pingPeer('100.64.0.5');
|
|
|
|
expect(ok, isFalse);
|
|
expect(calls.single.method, 'pingPeer');
|
|
});
|
|
});
|
|
|
|
group('recoverPaths over the method channel', () {
|
|
const channel = MethodChannel('tsnet_flutter');
|
|
late TsnetFlutter tsnet;
|
|
late List<MethodCall> calls;
|
|
Object? recoverError;
|
|
|
|
setUp(() {
|
|
tsnet = TsnetFlutter();
|
|
calls = [];
|
|
recoverError = null;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, (call) async {
|
|
calls.add(call);
|
|
if (call.method == 'recoverPaths') {
|
|
if (recoverError != null) throw recoverError!;
|
|
return null;
|
|
}
|
|
throw MissingPluginException(call.method);
|
|
});
|
|
});
|
|
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, null);
|
|
});
|
|
|
|
test('invokes recoverPaths with no arguments', () async {
|
|
await tsnet.recoverPaths();
|
|
|
|
expect(calls, hasLength(1));
|
|
expect(calls.single.method, 'recoverPaths');
|
|
expect(calls.single.arguments, isNull);
|
|
});
|
|
|
|
test('a stopped node surfaces as a PlatformException', () async {
|
|
recoverError = PlatformException(
|
|
code: 'RECOVER_FAILED',
|
|
message: 'not started, call TailscaleStart() first',
|
|
);
|
|
|
|
expect(
|
|
() => tsnet.recoverPaths(),
|
|
throwsA(
|
|
isA<PlatformException>().having(
|
|
(e) => e.code,
|
|
'code',
|
|
'RECOVER_FAILED',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('start over the method channel', () {
|
|
const channel = MethodChannel('tsnet_flutter');
|
|
late TsnetFlutter tsnet;
|
|
late List<MethodCall> calls;
|
|
|
|
setUp(() {
|
|
tsnet = TsnetFlutter();
|
|
calls = [];
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, (call) async {
|
|
calls.add(call);
|
|
if (call.method == 'start') return null;
|
|
throw MissingPluginException(call.method);
|
|
});
|
|
});
|
|
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, null);
|
|
});
|
|
|
|
test('debugNetLogging defaults to off', () async {
|
|
await tsnet.start(hostname: 'bench');
|
|
|
|
expect(calls.single.method, 'start');
|
|
expect(calls.single.arguments, {
|
|
'authKey': '',
|
|
'hostname': 'bench',
|
|
'debugNetLogging': false,
|
|
});
|
|
});
|
|
|
|
test('debugNetLogging is passed through when requested', () async {
|
|
await tsnet.start(hostname: 'bench', debugNetLogging: true);
|
|
|
|
expect(calls.single.arguments, {
|
|
'authKey': '',
|
|
'hostname': 'bench',
|
|
'debugNetLogging': true,
|
|
});
|
|
});
|
|
});
|
|
}
|