import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:fl_clash/common/common.dart'; 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'; class _FakeDAVClient extends DAVClient { _FakeDAVClient(super.dav, this.result, [this.onPing]); final Future result; final void Function()? onPing; @override Future 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 body; } typedef _ScriptedResponse = ({ String method, int status, Map 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> files = {}; final List<_ScriptedResponse> responses = []; String get uri => 'http://${_server.address.host}:${_server.port}'; Future close() => _server.close(force: true); Future _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> _collect(HttpRequest request) async { final bytes = []; await for (final chunk in request) { bytes.addAll(chunk); } return bytes; } Future _respond(HttpRequest request, List 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(); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); 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(); final secondResult = Completer(); 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(); 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 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.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.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().having( (error) => error.toString(), 'message', allOf(contains('GET'), contains('404')), ), ), ); }); test('a redirected download keeps the method', () async { final payload = List.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); }); }); }