refactor: remove proxy API (#1596)

This commit is contained in:
Levi authored and GitHub committed 2026-10-21 22:48:17 +08:00
1 parent 47cafb7575
commit 4214765d39
98 files changed
+5958 -1225

No files matched your search

@@ -0,0 +1,232 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/core/network/app_update_client.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
late Dio dio;
late _Adapter adapter;
late AppUpdateClient updates;
late _RejectTransport transport;
setUp(() {
transport = _RejectTransport();
api.setTransportForTesting(transport);
dio = Dio();
adapter = _Adapter();
dio.httpClientAdapter = adapter;
updates = AppUpdateClient(
client: dio,
site: GopeedSiteApi(client: dio),
);
});
tearDown(() {
dio.close(force: true);
expect(transport.calls, 0);
for (final request in adapter.requests) {
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
}
});
test('update metadata comes directly from the official GitHub API without mirror lookup', () async {
adapter.reply = (_, _) async => _json([
{'tag_name': 'v2.0.0', 'body': 'notes'},
]);
final info = await updates.check('1.0.0');
expect(info!.version, '2.0.0');
expect(info.changeLog, 'notes');
expect(info.releaseUrl, 'https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0');
expect(adapter.requests.single.uri.toString(), AppUpdateClient.releasesUrl);
});
test('release asset hashes are bound to the selected release and exact file name', () async {
final good = 'aB' * 32;
adapter.reply = (_, _) async => _json([
{
'tag_name': 'v1.9.9',
'assets': [
{'name': 'old.zip', 'digest': 'sha256:${'c' * 64}'},
],
},
{
'tag_name': 'v2.0.0',
'assets': [
{'name': 'package.zip', 'digest': 'sha256:$good'},
{'name': 'wrong-algorithm.zip', 'digest': 'md5:${'a' * 32}'},
{'name': 'missing.zip', 'digest': null},
{'name': 'bad-hex.zip', 'digest': 'sha256:${'g' * 64}'},
{'name': 'short.zip', 'digest': 'sha256:1234'},
],
},
]);
final info = (await updates.check('1.0.0'))!;
expect(info.assetSha256, {'package.zip': good.toLowerCase()});
expect(() => info.assetSha256['package.zip'] = 'd' * 64, throwsUnsupportedError);
expect(adapter.requests.single.followRedirects, isFalse);
});
test('the official website fallback preserves asset digests', () async {
final checksum = 'e' * 64;
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
? _json({'message': 'rate limited'}, status: 403)
: _json([
{
'tag_name': 'v2.0.0',
'assets': [
{'name': 'package.dmg', 'digest': 'sha256:$checksum'},
],
},
]);
final info = (await updates.check('1.0.0'))!;
expect(info.assetSha256, {'package.dmg': checksum});
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
});
test('API redirects fail instead of obtaining metadata from another host', () async {
adapter.reply = (_, _) async => ResponseBody.fromString(
'',
302,
headers: {
'location': ['https://mirror.example/releases'],
},
);
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
});
test('website fallback runs once after the official GitHub request fails', () async {
adapter.reply = (request, _) async => switch (request.uri.host) {
'api.github.com' => _json({'message': 'rate limited'}, status: 403),
_ => _json([
{'tag_name': 'v2.0.0'},
]),
};
expect((await updates.check('1.0.0'))!.version, '2.0.0');
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
final website = adapter.requests.where((r) => r.uri.host == 'gopeed.com').single;
expect(website.method, 'GET');
expect(website.uri.toString(), 'https://gopeed.com/api/releases?per_page=10');
});
test('invalid GitHub metadata falls back to valid website metadata', () async {
for (final invalid in [
null,
'<html>error</html>',
{'message': 'error'},
[
{'tag_name': 'invalid'},
],
]) {
adapter.requests.clear();
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
? _json(invalid)
: _json([
{'tag_name': 'v2.0.0'},
]);
expect((await updates.check('1.0.0'))!.version, '2.0.0');
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
}
});
test('valid empty and no-update lists do not fall back', () async {
for (final releases in [
[],
[
{'tag_name': 'v1.0.0'},
],
]) {
adapter.requests.clear();
adapter.reply = (_, _) async => _json(releases);
expect(await updates.check('1.0.0'), isNull);
expect(adapter.requests.single.uri.host, 'api.github.com');
}
});
test('null, objects, invalid releases and failed website responses propagate', () async {
for (final invalid in [
null,
{'message': 'error'},
[
{'tag_name': 'not a version'},
],
]) {
adapter.requests.clear();
adapter.reply = (_, _) async => _json(invalid);
await expectLater(updates.check('1.0.0'), throwsFormatException);
expect(adapter.requests.map((r) => r.uri.host), ['api.github.com', 'gopeed.com']);
}
adapter.reply = (_, _) async => _json({}, status: 503);
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
});
test('SemVer selection preserves stable and same-series preview rules', () async {
var releases = [
{'tag_name': 'v2.1.0-beta.1', 'prerelease': true},
{'tag_name': 'v2.0.0-beta.2', 'prerelease': true},
{'tag_name': 'v2.0.0-rc.1', 'prerelease': true},
{'tag_name': 'v1.9.9'},
{'tag_name': 'v2.0.0', 'draft': true},
];
adapter.reply = (_, _) async => _json(releases);
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-rc.1');
expect((await updates.check('1.9.8'))!.version, '1.9.9');
releases = [
{'tag_name': 'v2.0.0-beta.10'},
{'tag_name': 'v2.0.0-beta.2'},
];
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-beta.10');
releases = [
{'tag_name': 'v2.0.0'},
{'tag_name': 'v2.0.1'},
];
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.1');
releases = [
{'tag_name': 'v2.0.0+build.2'},
];
expect(await updates.check('2.0.0+build.1'), isNull);
await expectLater(updates.check('invalid'), throwsFormatException);
});
}
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
jsonEncode(body),
status,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
class _Adapter implements HttpClientAdapter {
final requests = <RequestOptions>[];
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
requests.add(options);
return reply(options, cancelFuture);
}
@override
void close({bool force = false}) {}
}
class _RejectTransport implements GopeedTransport {
int calls = 0;
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
calls++;
throw StateError('Update checks must not use the Downloader or mirror API');
}
@override
String join(String path) => path;
}
@@ -0,0 +1,196 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:crypto/crypto.dart';
import 'package:gopeed/core/network/candidate_download.dart';
void main() {
final expectedSha256 = sha256.convert([for (var i = 1; i <= 3; i++) ...List.filled(32768, i)]).toString();
late HttpServer server;
var requests = 0;
late Directory directory;
late String savePath;
late List<String> downloads;
late Future<void> Function(HttpRequest request) handle;
setUp(() async {
directory = await Directory.systemTemp.createTemp('gopeed-update-test-');
savePath = '${directory.path}/package.zip';
downloads = [];
requests = 0;
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((request) async {
requests++;
if (request.method == 'GET') downloads.add(request.uri.path);
await handle(request);
await request.response.close();
});
});
tearDown(() async {
await server.close(force: true);
await directory.delete(recursive: true);
});
String url(String path) => 'http://127.0.0.1:${server.port}$path';
Future<void> package(HttpRequest request) async {
const chunkSize = 32768;
request.response.headers.contentType = ContentType.binary;
request.response.contentLength = chunkSize * 3;
if (request.method == 'HEAD') return;
for (var i = 0; i < 3; i++) {
request.response.add(List.filled(chunkSize, i + 1));
await request.response.flush();
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
test('downloads the fastest successful probe and reports intermediate byte progress', () async {
handle = (request) async {
if (request.uri.path == '/slow' && request.method == 'HEAD') {
await Future<void>.delayed(const Duration(milliseconds: 120));
}
await package(request);
};
final progress = <(int, int)>[];
await downloadFromCandidates(
[url('/slow'), url('/fast'), url('/fast')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (received, total) => progress.add((received, total)),
);
expect(downloads, ['/fast']);
expect(await File(savePath).length(), 98304);
expect(progress.any((p) => p.$1 > 0 && p.$1 < p.$2), isTrue);
expect(progress.last, (98304, 98304));
});
test('falls back when a successful HEAD is followed by a failed GET', () async {
handle = (request) async {
if (request.uri.path == '/broken') {
request.response.headers.contentType = ContentType.binary;
if (request.method == 'GET') {
request.response.statusCode = HttpStatus.serviceUnavailable;
request.response.write('unavailable');
}
return;
}
if (request.method == 'HEAD') {
await Future<void>.delayed(const Duration(milliseconds: 60));
}
await package(request);
};
await downloadFromCandidates(
[url('/broken'), url('/working')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/broken', '/working']);
final bytes = await File(savePath).readAsBytes();
expect(bytes.length, 98304);
expect(bytes.take(32768), everyElement(1));
});
test('tries GET even when HEAD is unsupported', () async {
handle = (request) async {
if (request.method == 'HEAD') {
request.response.statusCode = HttpStatus.methodNotAllowed;
return;
}
await package(request);
};
await downloadFromCandidates(
[url('/no-head')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/no-head']);
expect(await File(savePath).length(), 98304);
});
test('bounds probe time and still allows a normal download', () async {
handle = (request) async {
if (request.method == 'HEAD') {
await Future<void>.delayed(const Duration(milliseconds: 100));
return;
}
await package(request);
};
await downloadFromCandidates(
[url('/head-timeout')],
savePath,
expectedSha256: expectedSha256,
probeTimeout: const Duration(milliseconds: 20),
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/head-timeout']);
expect(await File(savePath).length(), 98304);
});
test('rejects mirror landing pages and removes an unsuccessful download', () async {
handle = (request) async {
request.response.headers.contentType = ContentType.html;
if (request.method == 'GET') request.response.write('<html>mirror error</html>');
};
await expectLater(
downloadFromCandidates([url('/landing')], savePath, expectedSha256: expectedSha256, onReceiveProgress: (_, _) {}),
throwsStateError,
);
expect(await File(savePath).exists(), isFalse);
});
test('same-sized modified mirror bytes fail the official hash and fall back', () async {
handle = (request) async {
if (request.uri.path == '/tampered') {
request.response.headers.contentType = ContentType.binary;
// A mirror-provided hash must never replace the expected official hash.
request.response.headers.set('x-checksum-sha256', sha256.convert(List.filled(98304, 9)).toString());
request.response.contentLength = 98304;
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
return;
}
if (request.method == 'HEAD') await Future<void>.delayed(const Duration(milliseconds: 60));
await package(request);
};
await downloadFromCandidates(
[url('/tampered'), url('/official')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/tampered', '/official']);
expect((await sha256.bind(File(savePath).openRead()).first).toString(), expectedSha256);
});
test('all checksum failures abort and delete the untrusted package', () async {
handle = (request) async {
request.response.headers.contentType = ContentType.binary;
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
};
await expectLater(
downloadFromCandidates(
[url('/bad-one'), url('/bad-two')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
),
throwsA(isA<DownloadChecksumException>()),
);
expect(downloads.toSet(), {'/bad-one', '/bad-two'});
expect(await File(savePath).exists(), isFalse);
});
test('missing or malformed official checksums cannot start a probe or download', () async {
handle = package;
for (final checksum in ['', 'sha256:$expectedSha256', '0' * 63, 'g' * 64, '$expectedSha256\n']) {
await expectLater(
downloadFromCandidates([url('/official')], savePath, expectedSha256: checksum, onReceiveProgress: (_, _) {}),
throwsA(isA<DownloadChecksumException>()),
);
}
expect(requests, 0);
expect(await File(savePath).exists(), isFalse);
});
}
@@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/network/mirror_client.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
late _CandidatesTransport transport;
late Dio dio;
late _Adapter adapter;
late MirrorClient client;
const resourceUrl = 'https://raw.githubusercontent.com/owner/repo/main/manifest.json';
const mirror = 'https://mirror.example/https://raw.githubusercontent.com/owner/repo/main/manifest.json';
setUp(() {
transport = _CandidatesTransport([resourceUrl, mirror]);
api.setTransportForTesting(transport);
dio = Dio();
adapter = _Adapter();
dio.httpClientAdapter = adapter;
client = MirrorClient(service: LocalAppCapabilities.instance.capabilities.gopeed, provider: 'github', client: dio);
});
tearDown(() {
dio.close(force: true);
for (final request in adapter.requests) {
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
}
});
test('an invalid fast mirror cannot hide a slower valid release list', () async {
final official = Completer<ResponseBody>();
final mirrorRead = Completer<void>();
adapter.reply = (request, _) {
if (request.uri.host == 'raw.githubusercontent.com') return official.future;
mirrorRead.complete();
return Future.value(_json('<html>mirror error</html>'));
};
final result = client.getJson(resourceUrl, decode: _decodeList);
await mirrorRead.future;
official.complete(
_json([
{'tag_name': 'v2.0.0', 'body': 'notes'},
]),
);
final info = await result;
expect(info.single['tag_name'], 'v2.0.0');
expect(info.single['body'], 'notes');
expect(adapter.requests.any((r) => r.uri.host == 'gopeed.com'), isFalse);
expect(transport.requested, [resourceUrl]);
});
test('a complete validated winner cancels a stalled candidate', () async {
final officialStarted = Completer<void>();
final canceled = Completer<void>();
adapter.reply = (request, cancellation) async {
if (request.uri.host == 'raw.githubusercontent.com') {
officialStarted.complete();
await cancellation;
canceled.complete();
throw StateError('canceled');
}
await officialStarted.future;
return _json([
{'tag_name': 'v2.0.0'},
]);
};
expect((await client.getJson(resourceUrl, decode: _decodeList)).single['tag_name'], 'v2.0.0');
await canceled.future;
expect(adapter.requests.length, 2);
});
test('the mirror client supports other providers and non-GitHub resources', () async {
const resource = 'https://resources.example/manifest.json';
transport.provider = 'resources';
transport.urls = [resource, 'https://cdn.example/manifest.json'];
adapter.reply = (request, _) async =>
request.uri.host == 'resources.example' ? _json({}, status: 503) : _json({'revision': 42});
final client = MirrorClient(
service: LocalAppCapabilities.instance.capabilities.gopeed,
provider: 'resources',
client: dio,
);
final revision = await client.getJson<int>(resource, decode: (data) => (data as Map)['revision'] as int);
expect(revision, 42);
expect(transport.requested, [resource]);
expect(adapter.requests.map((request) => request.uri.host).toSet(), {'resources.example', 'cdn.example'});
});
test('metadata attempts have a total timeout even if the transport stalls', () async {
final canceled = Completer<void>();
adapter.reply = (_, cancellation) async {
await cancellation;
canceled.complete();
throw StateError('canceled');
};
transport.urls = [resourceUrl];
final github = MirrorClient(
service: LocalAppCapabilities.instance.capabilities.gopeed,
provider: 'github',
client: dio,
timeout: const Duration(milliseconds: 30),
);
await expectLater(github.getJson(resourceUrl, decode: (data) => data), throwsA(isA<TimeoutException>()));
await canceled.future;
});
}
List<dynamic> _decodeList(dynamic data) {
if (data is! List) throw const FormatException('Invalid manifest');
return data;
}
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
jsonEncode(body),
status,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
class _Adapter implements HttpClientAdapter {
final requests = <RequestOptions>[];
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
requests.add(options);
return reply(options, cancelFuture);
}
@override
void close({bool force = false}) {}
}
class _CandidatesTransport implements GopeedTransport {
_CandidatesTransport(this.urls);
List<String> urls;
String provider = 'github';
bool fail = false;
final requested = <String>[];
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
expect(path, 'api/v1/mirrors/$provider/candidates');
expect(method, 'POST');
requested.add((data as Map)['url'] as String);
if (fail) throw StateError('Downloader unavailable');
return {
'code': 0,
'data': {'urls': urls},
};
}
@override
String join(String path) => path;
}