mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
386 lines
14 KiB
Dart
386 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:spotiflac_android/services/motion_artwork_proxy.dart';
|
|
|
|
void main() {
|
|
late HttpClient reader;
|
|
setUp(() => reader = HttpClient());
|
|
tearDown(() => reader.close(force: true));
|
|
|
|
Future<({int status, HttpHeaders headers, List<int> bytes})> fetch(
|
|
Uri uri, {
|
|
String method = 'GET',
|
|
String? range,
|
|
}) async {
|
|
final request = await reader.openUrl(method, uri);
|
|
if (range != null) request.headers.set('range', range);
|
|
final response = await request.close();
|
|
return (
|
|
status: response.statusCode,
|
|
headers: response.headers,
|
|
bytes: await response.expand((chunk) => chunk).toList(),
|
|
);
|
|
}
|
|
|
|
Future<HttpServer> tlsServer() async {
|
|
final context = SecurityContext()
|
|
..useCertificateChain('test/fixtures/network_tls/cert.pem')
|
|
..usePrivateKey('test/fixtures/network_tls/key.pem');
|
|
final server = await HttpServer.bindSecure(
|
|
InternetAddress.loopbackIPv4,
|
|
0,
|
|
context,
|
|
);
|
|
addTearDown(() => server.close(force: true));
|
|
return server;
|
|
}
|
|
|
|
HttpClient trustedFixtureClient(HttpServer server) =>
|
|
HttpClient()
|
|
..badCertificateCallback = (_, host, port) =>
|
|
host == '127.0.0.1' && port == server.port;
|
|
|
|
test('TLS MP4 relays GET, HEAD, ranges, status and content type', () async {
|
|
final server = await tlsServer();
|
|
final requests = <(String, String?)>[];
|
|
server.listen((request) async {
|
|
requests.add((request.method, request.headers.value('range')));
|
|
request.response.headers.contentType = ContentType('video', 'mp4');
|
|
request.response.headers.set('accept-ranges', 'bytes');
|
|
if (request.headers.value('range') == 'bytes=1-2') {
|
|
request.response
|
|
..statusCode = 206
|
|
..headers.set('content-range', 'bytes 1-2/4')
|
|
..contentLength = 2
|
|
..add([20, 30]);
|
|
} else {
|
|
request.response.contentLength = 4;
|
|
if (request.method != 'HEAD') request.response.add([10, 20, 30, 40]);
|
|
}
|
|
await request.response.close();
|
|
}, onError: (Object _) {});
|
|
final proxy = await MotionArtworkProxy.start(
|
|
Uri.parse('https://127.0.0.1:${server.port}/cover.mp4'),
|
|
client: trustedFixtureClient(server),
|
|
);
|
|
addTearDown(proxy.close);
|
|
final full = await fetch(proxy.source);
|
|
expect(full.bytes, [10, 20, 30, 40]);
|
|
expect(full.headers.contentType?.mimeType, 'video/mp4');
|
|
final range = await fetch(proxy.source, range: 'bytes=1-2');
|
|
expect(range.status, 206);
|
|
expect(range.bytes, [20, 30]);
|
|
expect(range.headers.value('content-range'), 'bytes 1-2/4');
|
|
final head = await fetch(proxy.source, method: 'HEAD');
|
|
expect(head.status, 200);
|
|
expect(head.bytes, isEmpty);
|
|
expect(head.headers.contentLength, 4);
|
|
final count = requests.length;
|
|
expect(
|
|
(await fetch(proxy.source.replace(query: 'url=http://other'))).status,
|
|
404,
|
|
);
|
|
expect(
|
|
(await fetch(proxy.source.replace(path: '/unknown/0.bin'))).status,
|
|
404,
|
|
);
|
|
expect((await fetch(proxy.source, method: 'POST')).status, 405);
|
|
expect(requests.length, count);
|
|
expect(requests, [('GET', null), ('GET', 'bytes=1-2'), ('HEAD', null)]);
|
|
});
|
|
|
|
test(
|
|
'TLS stays verified unless the test explicitly trusts its fixture',
|
|
() async {
|
|
final server = await tlsServer();
|
|
var requests = 0;
|
|
server.listen((request) async {
|
|
requests++;
|
|
await request.response.close();
|
|
}, onError: (Object _) {});
|
|
final proxy = await MotionArtworkProxy.start(
|
|
Uri.parse('https://127.0.0.1:${server.port}/cover.mp4'),
|
|
);
|
|
addTearDown(proxy.close);
|
|
expect((await fetch(proxy.source)).status, 502);
|
|
expect(requests, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'HLS rewrites master, redirected media, key, map and byte ranges',
|
|
() async {
|
|
final server = await tlsServer();
|
|
final paths = <String>[];
|
|
server.listen((request) async {
|
|
paths.add(request.uri.toString());
|
|
final response = request.response;
|
|
switch (request.uri.path) {
|
|
case '/start.m3u8':
|
|
response.statusCode = 302;
|
|
response.headers.set('location', '/cdn/master.m3u8');
|
|
case '/cdn/master.m3u8':
|
|
response.write(
|
|
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100,CODECS="avc1.4"\nsub/media.m3u8\n',
|
|
);
|
|
case '/cdn/sub/media.m3u8':
|
|
response.statusCode = 302;
|
|
response.headers.set('location', '/final/media/list.m3u8');
|
|
case '/final/media/list.m3u8':
|
|
response.write('''#EXTM3U
|
|
#EXT-X-KEY:METHOD=AES-128,URI="../key.bin?token=secret"
|
|
#EXT-X-MAP:URI="../init.mp4",BYTERANGE="2@0"
|
|
#EXTINF:1,
|
|
one.ts
|
|
#EXT-X-BYTERANGE:2@1
|
|
../../shared.mp4
|
|
#EXTINF:1,
|
|
two.ts
|
|
#EXT-X-ENDLIST
|
|
''');
|
|
case '/final/key.bin':
|
|
response.add(List.filled(16, 42));
|
|
case '/final/init.mp4':
|
|
expect(request.headers.value('range'), 'bytes=0-1');
|
|
response
|
|
..statusCode = 206
|
|
..headers.set('content-range', 'bytes 0-1/4')
|
|
..add([1, 2]);
|
|
case '/shared.mp4':
|
|
expect(request.headers.value('range'), 'bytes=1-2');
|
|
response
|
|
..statusCode = 206
|
|
..headers.set('content-range', 'bytes 1-2/4')
|
|
..add([20, 30]);
|
|
case '/final/media/one.ts':
|
|
response.add([4, 5]);
|
|
case '/final/media/two.ts':
|
|
response.add([6, 7]);
|
|
default:
|
|
response.statusCode = 404;
|
|
}
|
|
await response.close();
|
|
}, onError: (Object _) {});
|
|
final proxy = await MotionArtworkProxy.start(
|
|
Uri.parse('https://127.0.0.1:${server.port}/start.m3u8'),
|
|
client: trustedFixtureClient(server),
|
|
);
|
|
addTearDown(proxy.close);
|
|
final master = utf8.decode((await fetch(proxy.source)).bytes);
|
|
final child = Uri.parse(master.split('\n').last);
|
|
expect(child.scheme, 'http');
|
|
expect(child.host, '127.0.0.1');
|
|
final media = utf8.decode((await fetch(child)).bytes);
|
|
expect(media, isNot(contains('https:')));
|
|
expect(media, contains('#EXT-X-BYTERANGE:2@1'));
|
|
expect(media, contains('BYTERANGE="2@0"'));
|
|
final attributes = RegExp(
|
|
r'URI="([^"]+)"',
|
|
).allMatches(media).map((match) => Uri.parse(match[1]!)).toList();
|
|
final segments = const LineSplitter()
|
|
.convert(media)
|
|
.where((line) => line.isNotEmpty && !line.startsWith('#'))
|
|
.map(Uri.parse)
|
|
.toList();
|
|
expect((await fetch(attributes[0])).bytes, List.filled(16, 42));
|
|
expect((await fetch(attributes[1], range: 'bytes=0-1')).bytes, [1, 2]);
|
|
expect((await fetch(segments[0])).bytes, [4, 5]);
|
|
expect((await fetch(segments[1], range: 'bytes=1-2')).bytes, [20, 30]);
|
|
expect((await fetch(segments[2])).bytes, [6, 7]);
|
|
expect(paths, contains('/final/key.bin?token=secret'));
|
|
expect(paths, contains('/cdn/sub/media.m3u8'));
|
|
expect(paths, contains('/final/media/list.m3u8'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'binary responses start streaming before the upstream finishes',
|
|
() async {
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
addTearDown(() => server.close(force: true));
|
|
final continueBody = Completer<void>();
|
|
server.listen((request) async {
|
|
request.response.add(List.filled(16384, 1));
|
|
await request.response.flush();
|
|
await continueBody.future;
|
|
request.response.add(List.filled(1024 * 1024, 2));
|
|
await request.response.close();
|
|
});
|
|
final proxy = await MotionArtworkProxy.start(
|
|
Uri.parse('http://127.0.0.1:${server.port}/cover.mp4'),
|
|
);
|
|
addTearDown(proxy.close);
|
|
final response = await (await reader.getUrl(proxy.source)).close();
|
|
var bytes = 0;
|
|
await for (final chunk in response.timeout(const Duration(seconds: 2))) {
|
|
bytes += chunk.length;
|
|
if (!continueBody.isCompleted) continueBody.complete();
|
|
}
|
|
expect(bytes, 16384 + 1024 * 1024);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'large declared MP4 supports partial reads and consumer cancellation',
|
|
() async {
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
addTearDown(() => server.close(force: true));
|
|
final stop = Completer<void>();
|
|
server.listen((request) async {
|
|
expect(request.headers.value('range'), 'bytes=0-');
|
|
request.response
|
|
..bufferOutput = false
|
|
..statusCode = 206
|
|
..headers.set('content-range', 'bytes 0-67108863/67108864')
|
|
..contentLength = 64 << 20
|
|
..add(List.filled(1024, 1));
|
|
await request.response.flush();
|
|
await stop.future;
|
|
try {
|
|
await request.response.close();
|
|
} catch (_) {
|
|
// FFmpeg stops after reading its clip instead of downloading 64 MiB.
|
|
}
|
|
});
|
|
final proxy = await MotionArtworkProxy.start(
|
|
Uri.parse('http://127.0.0.1:${server.port}/cover.mp4'),
|
|
maxBytes: 4096,
|
|
);
|
|
addTearDown(proxy.close);
|
|
final request = await reader.getUrl(proxy.source);
|
|
request.headers.set('range', 'bytes=0-');
|
|
final response = await request.close();
|
|
expect(response.statusCode, 206);
|
|
expect(response.contentLength, 64 << 20);
|
|
final body = StreamIterator(response);
|
|
expect(await body.moveNext(), isTrue);
|
|
expect(body.current, everyElement(1));
|
|
expect(proxy.isClosed, isFalse);
|
|
await body.cancel();
|
|
stop.complete();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'gzip playlists rewrite URI attributes and preserve unrelated names',
|
|
() async {
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
addTearDown(() => server.close(force: true));
|
|
server.listen((request) async {
|
|
request.response.headers.contentType = ContentType(
|
|
'application',
|
|
'vnd.apple.mpegurl',
|
|
);
|
|
request.response.headers.set('content-encoding', 'gzip');
|
|
request.response.add(
|
|
gzip.encode(
|
|
utf8.encode('''#EXTM3U
|
|
#EXT-X-UNKNOWN:SERVER-URI="urn:ignored",X-FOO-URI="file:///ignored"
|
|
#EXT-X-KEY:METHOD=AES-128,URI="key.bin"
|
|
#EXTINF:1,
|
|
one.ts
|
|
#EXT-X-ENDLIST
|
|
'''),
|
|
),
|
|
);
|
|
await request.response.close();
|
|
});
|
|
final proxy = await MotionArtworkProxy.start(
|
|
Uri.parse('http://127.0.0.1:${server.port}/opaque'),
|
|
);
|
|
addTearDown(proxy.close);
|
|
expect(proxy.isHls, isFalse);
|
|
final response = await fetch(proxy.source);
|
|
expect(proxy.isHls, isTrue);
|
|
expect(response.status, 200);
|
|
expect(response.headers.value('content-encoding'), isNull);
|
|
final text = utf8.decode(response.bytes);
|
|
expect(
|
|
text,
|
|
contains('SERVER-URI="urn:ignored",X-FOO-URI="file:///ignored"'),
|
|
);
|
|
expect(text, contains('METHOD=AES-128,URI="http://127.0.0.1:'));
|
|
expect(
|
|
text.split('\n').where((line) => line.startsWith('http')).single,
|
|
endsWith('.ts'),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'cumulative byte limit closes the proxy instead of retaining video',
|
|
() async {
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
addTearDown(() => server.close(force: true));
|
|
server.listen((request) async {
|
|
request.response
|
|
..contentLength = 5
|
|
..add([1, 2, 3, 4, 5]);
|
|
await request.response.close();
|
|
});
|
|
final proxy = await MotionArtworkProxy.start(
|
|
Uri.parse('http://127.0.0.1:${server.port}/cover.mp4'),
|
|
maxBytes: 8,
|
|
);
|
|
addTearDown(proxy.close);
|
|
expect((await fetch(proxy.source)).bytes, [1, 2, 3, 4, 5]);
|
|
await expectLater(fetch(proxy.source), throwsA(isA<HttpException>()));
|
|
expect(proxy.isClosed, isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'deadline and failed consumers close their client and loopback server',
|
|
() async {
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
addTearDown(() => server.close(force: true));
|
|
server.listen((request) async {
|
|
request.response.write('video');
|
|
await request.response.close();
|
|
});
|
|
final source = Uri.parse('http://127.0.0.1:${server.port}/cover.mp4');
|
|
Uri? expired;
|
|
final client = HttpClient();
|
|
await expectLater(
|
|
withMotionArtworkProxy<void>(source, (local) async {
|
|
expired = local;
|
|
throw StateError('remux failed');
|
|
}, client: client),
|
|
throwsStateError,
|
|
);
|
|
await expectLater(fetch(expired!), throwsA(isA<SocketException>()));
|
|
expect(() => client.getUrl(source), throwsStateError);
|
|
final proxy = await MotionArtworkProxy.start(
|
|
source,
|
|
lifetime: const Duration(milliseconds: 30),
|
|
);
|
|
addTearDown(proxy.close);
|
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
|
expect(proxy.isClosed, isTrue);
|
|
await expectLater(fetch(proxy.source), throwsA(isA<SocketException>()));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'playlist references cannot enable local file or non-HTTP resources',
|
|
() async {
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
addTearDown(() => server.close(force: true));
|
|
server.listen((request) async {
|
|
request.response.write(
|
|
'#EXTM3U\n#EXT-X-KEY:URI="file:///private/key"\n',
|
|
);
|
|
await request.response.close();
|
|
});
|
|
final proxy = await MotionArtworkProxy.start(
|
|
Uri.parse('http://127.0.0.1:${server.port}/cover.m3u8'),
|
|
);
|
|
addTearDown(proxy.close);
|
|
expect((await fetch(proxy.source)).status, 502);
|
|
},
|
|
);
|
|
}
|