Files
FlClash/test/common/dav_client_test.dart
T

357 lines
10 KiB
Dart
Raw Normal View History

2026-08-05 10:57:11 +08:00
import 'dart:async';
import 'dart:convert';
import 'dart:io';
2026-08-05 10:57:11 +08:00
import 'package:fl_clash/common/common.dart';
2026-08-05 10:57:11 +08:00
import 'package:fl_clash/common/dav_client.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import '../helpers/app_paths.dart';
2026-08-05 10:57:11 +08:00
class _FakeDAVClient extends DAVClient {
_FakeDAVClient(super.dav, this.result, [this.onPing]);
final Future<bool> result;
final void Function()? onPing;
@override
Future<bool> ping() {
onPing?.call();
return result;
}
}
class _RecordedRequest {
_RecordedRequest(this.method, this.path, this.authorization, this.body);
final String method;
final String path;
final String? authorization;
final List<int> body;
}
typedef _ScriptedResponse = ({
String method,
int status,
Map<String, String> headers,
});
/// A stand-in WebDAV origin: the tests drive the real client against a real
/// socket, so status handling, auth and redirects are exercised end to end.
class _TestServer {
_TestServer(this._server) {
unawaited(_serve());
}
static Future<_TestServer> start() async {
return _TestServer(await HttpServer.bind(InternetAddress.loopbackIPv4, 0));
}
final HttpServer _server;
final List<_RecordedRequest> requests = [];
final Map<String, List<int>> files = {};
final List<_ScriptedResponse> responses = [];
String get uri => 'http://${_server.address.host}:${_server.port}';
Future<void> close() => _server.close(force: true);
Future<void> _serve() async {
await for (final request in _server) {
final body = await _collect(request);
requests.add(
_RecordedRequest(
request.method,
request.uri.path,
request.headers.value(HttpHeaders.authorizationHeader),
body,
),
);
final index = responses.indexWhere(
(item) => item.method == request.method,
);
if (index != -1) {
final scripted = responses.removeAt(index);
request.response.statusCode = scripted.status;
scripted.headers.forEach(request.response.headers.set);
await request.response.close();
continue;
}
await _respond(request, body);
}
}
Future<List<int>> _collect(HttpRequest request) async {
final bytes = <int>[];
await for (final chunk in request) {
bytes.addAll(chunk);
}
return bytes;
}
Future<void> _respond(HttpRequest request, List<int> body) async {
final response = request.response;
final path = request.uri.path;
switch (request.method) {
case 'OPTIONS':
response.headers.set('dav', '1,2');
response.statusCode = HttpStatus.ok;
case 'MKCOL':
response.statusCode = files.containsKey(path)
? HttpStatus.methodNotAllowed
: HttpStatus.created;
files.putIfAbsent(path, () => const []);
case 'PUT':
files[path] = body;
response.statusCode = HttpStatus.created;
case 'GET':
final stored = files[path];
if (stored == null) {
response.statusCode = HttpStatus.notFound;
} else {
response.statusCode = HttpStatus.ok;
response.add(stored);
}
default:
response.statusCode = HttpStatus.methodNotAllowed;
}
await response.close();
}
}
2026-08-05 10:57:11 +08:00
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
2026-08-05 10:57:11 +08:00
const firstProps = DAVProps(
uri: 'https://dav.example.com',
user: 'first',
password: 'first-password',
);
const secondProps = DAVProps(
uri: 'https://dav.example.com',
user: 'second',
password: 'second-password',
);
test('latest DAV connectivity check wins', () async {
final firstResult = Completer<bool>();
final secondResult = Completer<bool>();
final results = [firstResult.future, secondResult.future];
var index = 0;
final controller = DAVConnectionController(
createClient: (props) {
return _FakeDAVClient(props, results[index++]);
},
);
addTearDown(controller.dispose);
final firstUpdate = controller.update(firstProps);
final secondUpdate = controller.update(secondProps);
secondResult.complete(true);
await secondUpdate;
expect(controller.value, isTrue);
firstResult.complete(false);
await firstUpdate;
expect(controller.value, isTrue);
});
test('changing only the remote file name does not ping again', () async {
var pingCount = 0;
final controller = DAVConnectionController(
createClient: (props) {
return _FakeDAVClient(props, Future.value(true), () => pingCount++);
},
);
addTearDown(controller.dispose);
await controller.update(firstProps);
await controller.update(firstProps.copyWith(fileName: 'another.zip'));
expect(pingCount, 1);
expect(controller.value, isTrue);
expect(controller.client?.fileName, 'another.zip');
});
test('new credentials reset connectivity while ping is pending', () async {
final result = Completer<bool>();
final controller = DAVConnectionController(
createClient: (props) {
return _FakeDAVClient(props, result.future);
},
);
addTearDown(controller.dispose);
final update = controller.update(firstProps);
expect(controller.value, isNull);
result.complete(true);
await update;
expect(controller.value, isTrue);
});
group('against a live server', () {
late _TestServer server;
late Directory root;
HttpOverrides? testOverrides;
setUpAll(() async {
root = Directory.systemTemp.createTempSync('dav_client_test');
await useAppPaths(root);
});
tearDownAll(() {
try {
root.deleteSync(recursive: true);
} catch (_) {}
});
setUp(() async {
// The widget test binding installs an override that answers every
// request with 400 instead of opening a socket.
testOverrides = HttpOverrides.current;
HttpOverrides.global = null;
server = await _TestServer.start();
});
tearDown(() async {
HttpOverrides.global = testOverrides;
await server.close();
});
DAVClient buildClient({String path = ''}) {
return DAVClient(
DAVProps(
uri: '${server.uri}$path',
user: 'user',
password: 'secret',
fileName: 'backup.zip',
),
);
}
File writeArchive(List<int> bytes) {
return File(join(root.path, 'source.zip'))..writeAsBytesSync(bytes);
}
test('ping reports a server that answers OPTIONS', () async {
expect(await buildClient().ping(), isTrue);
expect(server.requests.single.method, 'OPTIONS');
});
test('ping reports a server that refuses the credentials', () async {
server.responses.add((
method: 'OPTIONS',
status: HttpStatus.unauthorized,
headers: const {},
));
expect(await buildClient().ping(), isFalse);
});
test('upload creates the collection and puts the archive', () async {
final archive = writeArchive(List<int>.generate(32, (index) => index));
await buildClient().upload(archive.path);
expect(server.requests.map((item) => item.method), ['MKCOL', 'PUT']);
expect(server.requests.last.path, '/$appName/backup.zip');
expect(server.files['/$appName/backup.zip'], archive.readAsBytesSync());
});
test('a base path prefixes every request', () async {
await buildClient(path: '/remote.php/dav').ping();
expect(server.requests.single.path, '/remote.php/dav/');
});
test('an existing collection is not an error', () async {
final archive = writeArchive(const [1, 2, 3]);
final client = buildClient();
await client.upload(archive.path);
await expectLater(client.upload(archive.path), completes);
});
test('download writes the remote archive to the given path', () async {
final payload = List<int>.generate(64, (index) => index);
server.files['/$appName/backup.zip'] = payload;
final target = join(root.path, 'download.zip');
await buildClient().download(target);
expect(File(target).readAsBytesSync(), payload);
});
test('download reports the status the server sent', () async {
await expectLater(
buildClient().download(join(root.path, 'missing.zip')),
throwsA(
isA<DAVException>().having(
(error) => error.toString(),
'message',
allOf(contains('GET'), contains('404')),
),
),
);
});
test('a redirected download keeps the method', () async {
final payload = List<int>.generate(8, (index) => index);
server.files['/moved/backup.zip'] = payload;
server.responses.add((
method: 'GET',
status: HttpStatus.movedPermanently,
headers: const {'location': '/moved/backup.zip'},
));
await buildClient().download(join(root.path, 'moved.zip'));
expect(server.requests.map((item) => item.method), ['GET', 'GET']);
expect(File(join(root.path, 'moved.zip')).readAsBytesSync(), payload);
});
test('credentials are sent as basic auth', () async {
await buildClient().ping();
expect(
server.requests.single.authorization,
'Basic ${base64.encode(utf8.encode('user:secret'))}',
);
});
test('a digest challenge is answered and retried once', () async {
server.responses.add((
method: 'OPTIONS',
status: HttpStatus.unauthorized,
headers: const {
'www-authenticate':
'Digest realm="dav", qop="auth", nonce="abc123", opaque="op"',
},
));
expect(await buildClient().ping(), isTrue);
expect(server.requests, hasLength(2));
expect(server.requests.first.authorization, startsWith('Basic '));
final retry = server.requests.last.authorization!;
expect(retry, startsWith('Digest '));
expect(retry, contains('username="user"'));
expect(retry, contains('nonce="abc123"'));
expect(retry, contains('qop=auth'));
expect(retry, contains('nc=00000001'));
});
test('anonymous access sends no authorization header', () async {
await DAVClient(DAVProps(uri: server.uri, user: '', password: '')).ping();
expect(server.requests.single.authorization, isNull);
});
});
2026-08-05 10:57:11 +08:00
}