mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
Move WebDAV/HTML parsing, content-addressed artwork caching, cover promotion, and streamed upload verification to native workers. Preserve proxy authentication, cancellation, cache limits, and deep XML fallback behavior.
483 lines
17 KiB
Dart
483 lines
17 KiB
Dart
// Host-only native library injection into the SMB worker isolates.
|
|
// ignore_for_file: invalid_use_of_internal_member
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
// Host tests load the pinned native library used by the mobile plugin.
|
|
// ignore: implementation_imports
|
|
import 'package:dart_smb2/src/ffi/native_lib.dart';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:spotiflac_android/services/network_storage_service.dart';
|
|
import 'package:spotiflac_android/services/network_storage_error.dart';
|
|
|
|
NetworkConnection connection(
|
|
String url, {
|
|
NetworkProtocol protocol = NetworkProtocol.http,
|
|
String id = 'server',
|
|
String user = '',
|
|
String password = '',
|
|
}) => NetworkConnection(
|
|
id: id,
|
|
name: 'Music server',
|
|
protocol: protocol,
|
|
address: url,
|
|
username: user,
|
|
password: password,
|
|
);
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
// Listing now crosses a method channel before the isolated host fallback.
|
|
// Keep the in-process transport fixtures on real HTTP clients.
|
|
HttpOverrides.global = null;
|
|
|
|
test('paths retain literal percent, spaces, Unicode, plus and hash', () {
|
|
final c = connection('https://example.test/Music%20Library/');
|
|
const path = '日本語/100% + #1.flac';
|
|
final uri = networkTarget(c, path);
|
|
expect(uri.pathSegments, ['Music Library', '日本語', '100% + #1.flac']);
|
|
expect(
|
|
Uri.parse(NetworkStorageService.source('server', path)).pathSegments,
|
|
['日本語', '100% + #1.flac'],
|
|
);
|
|
for (final invalid in [
|
|
'../secret',
|
|
'/secret',
|
|
'a/../b',
|
|
'a\\b',
|
|
'a\u0000b',
|
|
]) {
|
|
expect(() => networkTarget(c, invalid), throwsFormatException);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'single HTTP byte ranges include suffix and reject malformed ranges',
|
|
() {
|
|
expect(networkByteRange(null, 10), (start: 0, end: 9));
|
|
expect(networkByteRange('bytes=3-', 10), (start: 3, end: 9));
|
|
expect(networkByteRange('bytes=-4', 10), (start: 6, end: 9));
|
|
expect(networkByteRange('bytes=5-20', 10), (start: 5, end: 9));
|
|
for (final range in [
|
|
'bytes=10-',
|
|
'bytes=-0',
|
|
'bytes=5-2',
|
|
'bytes=0-1,3-4',
|
|
'bytes=-',
|
|
]) {
|
|
expect(() => networkByteRange(range, 10), throwsFormatException);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'HTML indexes are confined to immediate children of the configured root',
|
|
() {
|
|
final result = NetworkStorageService.parseListing(
|
|
connection('https://example.test/music/'),
|
|
'',
|
|
'''
|
|
<a href="../">Parent</a><a href="album/">Album</a>
|
|
<a href="song%20%23%25.flac">Song</a><a href="song%20%23%25.flac">Duplicate</a>
|
|
<a href="https://other.test/music/a.mp3">Other server</a>
|
|
<a href="/outside/a.mp3">Outside root</a><a href="%2e%2e/secret.mp3">Traversal</a>
|
|
<a href="a/b.mp3">Nested</a><a href="?sort=name">Sort</a>
|
|
''',
|
|
);
|
|
expect(result.map((e) => e.path), ['album/', 'song #%.flac']);
|
|
expect(result.first.directory, true);
|
|
},
|
|
);
|
|
|
|
test('WebDAV reads successful properties and encoded child paths only', () {
|
|
final result = NetworkStorageService.parseListing(
|
|
connection('https://example.test/dav/', protocol: NetworkProtocol.webdav),
|
|
'Album/',
|
|
'''
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response><d:href>/dav/Album/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
<d:response><d:href>/dav/Album/A%20B.flac</d:href><d:propstat><d:prop><d:getcontentlength>123</d:getcontentlength></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
<d:response><d:href>/dav/Album/Missing.flac</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>
|
|
</d:multistatus>''',
|
|
);
|
|
expect(result.single.path, 'Album/A B.flac');
|
|
expect(result.single.size, 123);
|
|
});
|
|
|
|
test(
|
|
'concurrent saves persist both connections and retain credentials privately',
|
|
() async {
|
|
String? stored;
|
|
final service = NetworkStorageService(
|
|
read: () async => stored,
|
|
write: (s) async {
|
|
stored = s;
|
|
},
|
|
);
|
|
final empty = await service.connections();
|
|
await Future.wait([
|
|
service.save(
|
|
connection(
|
|
'https://example.test/',
|
|
id: 'one',
|
|
user: 'user',
|
|
password: 'secret',
|
|
),
|
|
),
|
|
service.save(
|
|
connection(
|
|
'smb://server/music/',
|
|
id: 'two',
|
|
protocol: NetworkProtocol.smb,
|
|
),
|
|
),
|
|
]);
|
|
final saved = await service.connections();
|
|
expect(empty, isEmpty);
|
|
expect(saved.length, 2);
|
|
expect(await service.connections(), same(saved));
|
|
expect(() => saved.clear(), throwsUnsupportedError);
|
|
final restored = NetworkStorageService(
|
|
read: () async => stored,
|
|
write: (_) async {},
|
|
);
|
|
final loaded = await restored.connections();
|
|
expect(await restored.connections(), same(loaded));
|
|
expect(() => loaded.clear(), throwsUnsupportedError);
|
|
expect((await restored.connection('one')).password, 'secret');
|
|
final source = NetworkStorageService.source('one', 'test.flac');
|
|
expect(source, 'network://one/test.flac');
|
|
await service.remove('one');
|
|
final removed = await service.connections();
|
|
expect(removed.single.id, 'two');
|
|
expect(await service.connections(), same(removed));
|
|
expect(saved.length, 2);
|
|
expect(() => removed.clear(), throwsUnsupportedError);
|
|
},
|
|
);
|
|
|
|
group('real HTTP transport', () {
|
|
late HttpServer server;
|
|
late NetworkStorageService service;
|
|
late String origin;
|
|
final requests = <String>[];
|
|
List<List<int>>? listingChunks;
|
|
setUp(() async {
|
|
requests.clear();
|
|
listingChunks = null;
|
|
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
origin = 'http://127.0.0.1:${server.port}';
|
|
service = NetworkStorageService(
|
|
read: () async => null,
|
|
write: (_) async {},
|
|
);
|
|
server.listen((r) async {
|
|
requests.add('${r.method} ${r.uri.path}');
|
|
if (r.headers.value(HttpHeaders.authorizationHeader) !=
|
|
'Basic ${base64Encode(utf8.encode('test:secret'))}') {
|
|
r.response.statusCode = 401;
|
|
} else if (r.uri.path == '/music') {
|
|
r.response.statusCode = 301;
|
|
r.response.headers.set('location', '/music/');
|
|
} else if (r.uri.path == '/external') {
|
|
r.response.statusCode = 302;
|
|
r.response.headers.set('location', 'https://elsewhere.invalid/');
|
|
} else if (r.uri.path == '/chunks/') {
|
|
try {
|
|
for (final chunk in listingChunks!) {
|
|
r.response.add(chunk);
|
|
await r.response.flush();
|
|
}
|
|
await r.response.close();
|
|
} on SocketException {
|
|
// The client closes the response when it exceeds the size limit.
|
|
} on HttpException {
|
|
// A rejected response can also fail an in-flight flush.
|
|
}
|
|
return;
|
|
} else if (r.method == 'PROPFIND') {
|
|
expect(r.headers.value('Depth'), '1');
|
|
await r.drain<void>();
|
|
r.response.statusCode = 207;
|
|
r.response.write(
|
|
'<d:multistatus xmlns:d="DAV:"><d:response><d:href>/music/A%20B.flac</d:href><d:propstat><d:prop><d:getcontentlength>10</d:getcontentlength></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>',
|
|
);
|
|
} else if (r.uri.path.endsWith('/')) {
|
|
r.response.write('<a href="A%20B.flac">Audio</a>');
|
|
} else {
|
|
final part = networkByteRange(r.headers.value('range'), 10);
|
|
r.response.statusCode = r.headers.value('range') == null ? 200 : 206;
|
|
r.response.headers.set('content-type', 'audio/flac');
|
|
r.response.headers.set('accept-ranges', 'bytes');
|
|
r.response.headers.set(
|
|
'content-range',
|
|
'bytes ${part.start}-${part.end}/10',
|
|
);
|
|
r.response.contentLength = part.end - part.start + 1;
|
|
if (r.method != 'HEAD') {
|
|
r.response.add(
|
|
List.generate(10, (i) => i).sublist(part.start, part.end + 1),
|
|
);
|
|
}
|
|
}
|
|
await r.response.close();
|
|
});
|
|
});
|
|
tearDown(() async {
|
|
await service.dispose();
|
|
await server.close(force: true);
|
|
});
|
|
|
|
test('chunked listings preserve Unicode and malformed UTF-8', () async {
|
|
final body = [
|
|
...utf8.encode('<a href="日本語.flac">'),
|
|
0xff,
|
|
...utf8.encode('</a><a href="Album/">Album</a>'),
|
|
];
|
|
// One-byte writes split both three-byte code points and HTML tokens.
|
|
listingChunks = [
|
|
for (final byte in body) [byte],
|
|
];
|
|
final c = connection('$origin/chunks/', user: 'test', password: 'secret');
|
|
final entries = await service.list(c);
|
|
expect(entries.map((entry) => entry.path), ['Album/', '日本語.flac']);
|
|
expect(entries.last.name, '日本語.flac');
|
|
expect(entries.first.directory, true);
|
|
});
|
|
|
|
test(
|
|
'listing size limit accepts 4 MiB and rejects the next byte',
|
|
() async {
|
|
const limit = 4 * 1024 * 1024;
|
|
final body = Uint8List(limit + 1)..fillRange(0, limit + 1, 32);
|
|
final prefix = utf8.encode('<a href="Song.flac">Song</a>');
|
|
body.setRange(0, prefix.length, prefix);
|
|
listingChunks = [
|
|
for (var offset = 0; offset < limit; offset += 64 * 1024)
|
|
Uint8List.sublistView(body, offset, offset + 64 * 1024),
|
|
];
|
|
final c = connection(
|
|
'$origin/chunks/',
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
expect((await service.list(c)).single.name, 'Song.flac');
|
|
listingChunks = [...listingChunks!, Uint8List.sublistView(body, limit)];
|
|
await expectLater(
|
|
service.list(c),
|
|
throwsA(
|
|
isA<HttpException>().having(
|
|
(error) => error.message,
|
|
'message',
|
|
'Directory listing is too large',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'authenticated listing, proxy range seek, HEAD, and revocation',
|
|
() async {
|
|
final c = connection(
|
|
'$origin/music/',
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
await service.save(c);
|
|
expect((await service.list(c)).single.name, 'A B.flac');
|
|
final url = await service.resolve(
|
|
NetworkStorageService.source(c.id, 'A B.flac'),
|
|
);
|
|
expect(url, isNot(contains('secret')));
|
|
final client = HttpClient();
|
|
addTearDown(() => client.close(force: true));
|
|
final request = await client.getUrl(Uri.parse(url));
|
|
request.headers.set('range', 'bytes=4-7');
|
|
final response = await request.close();
|
|
expect(response.statusCode, 206);
|
|
expect(response.headers.value('content-range'), 'bytes 4-7/10');
|
|
expect(await response.expand((e) => e).toList(), [4, 5, 6, 7]);
|
|
final head = await (await client.openUrl(
|
|
'HEAD',
|
|
Uri.parse(url),
|
|
)).close();
|
|
expect(head.contentLength, 10);
|
|
await head.drain<void>();
|
|
await service.remove(c.id);
|
|
final removed = await (await client.getUrl(Uri.parse(url))).close();
|
|
expect(removed.statusCode, 404);
|
|
await removed.drain<void>();
|
|
},
|
|
);
|
|
|
|
test('WebDAV and direct HTTP file validation use real requests', () async {
|
|
final dav = connection(
|
|
'$origin/music/',
|
|
protocol: NetworkProtocol.webdav,
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
expect((await service.list(dav)).single.size, 10);
|
|
final direct = connection(
|
|
'$origin/song.mp3',
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
expect((await service.list(direct)).single.path, '');
|
|
expect(requests, contains('GET /song.mp3'));
|
|
await expectLater(
|
|
service.list(connection('$origin/music/')),
|
|
throwsA(isA<HttpException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'same origin redirects work; cross origin redirects cannot leak credentials',
|
|
() async {
|
|
expect(
|
|
(await service.list(
|
|
connection('$origin/music', user: 'test', password: 'secret'),
|
|
)).single.audio,
|
|
true,
|
|
);
|
|
await expectLater(
|
|
service.list(
|
|
connection('$origin/external', user: 'test', password: 'secret'),
|
|
),
|
|
throwsA(isA<HttpException>()),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
test('SMB server browsing and byte-range streaming', () async {
|
|
debugLibSmb2PathOverride = Platform.environment['TEST_SMB_LIBRARY'];
|
|
final port = Platform.environment['TEST_SMB_PORT'] ?? '1445';
|
|
addTearDown(() => debugLibSmb2PathOverride = null);
|
|
final service = NetworkStorageService(
|
|
read: () async => null,
|
|
write: (_) async {},
|
|
);
|
|
addTearDown(service.dispose);
|
|
final c = connection(
|
|
'smb://127.0.0.1:$port/MUSIC/',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
await service.save(c);
|
|
final shares = await service.list(
|
|
connection(
|
|
'smb://127.0.0.1:$port/',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'secret',
|
|
),
|
|
);
|
|
expect(
|
|
shares.any((entry) => entry.name == 'MUSIC' && entry.directory),
|
|
true,
|
|
);
|
|
final entries = await service.list(c);
|
|
expect(entries.any((e) => e.name == 'A B.wav'), true);
|
|
final domainConnection = NetworkConnection(
|
|
id: c.id,
|
|
name: c.name,
|
|
protocol: c.protocol,
|
|
address: c.address,
|
|
username: c.username,
|
|
password: c.password,
|
|
domain: 'WORKGROUP',
|
|
);
|
|
expect(
|
|
(await service.list(domainConnection)).any((e) => e.name == 'A B.wav'),
|
|
true,
|
|
);
|
|
final withoutSlash = connection(
|
|
'smb://127.0.0.1:$port/MUSIC',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'secret',
|
|
);
|
|
expect(
|
|
(await service.list(withoutSlash)).any((e) => e.name == 'A B.wav'),
|
|
true,
|
|
);
|
|
await expectLater(
|
|
service.list(
|
|
connection(
|
|
'smb://127.0.0.1:$port/DOES_NOT_EXIST',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'secret',
|
|
),
|
|
),
|
|
throwsA(
|
|
predicate<Object>(
|
|
(error) =>
|
|
classifyNetworkStorageError(error) ==
|
|
NetworkStorageError.missingShare,
|
|
),
|
|
),
|
|
);
|
|
final url = await service.resolve(
|
|
NetworkStorageService.source(c.id, 'A B.wav'),
|
|
);
|
|
final client = HttpClient();
|
|
addTearDown(() => client.close(force: true));
|
|
final request = await client.getUrl(Uri.parse(url));
|
|
request.headers.set('range', 'bytes=4-7');
|
|
final response = await request.close();
|
|
expect(response.statusCode, 206);
|
|
expect(await response.expand((e) => e).toList(), [4, 5, 6, 7]);
|
|
final head = await client.headUrl(Uri.parse(url));
|
|
final headResponse = await head.close();
|
|
expect(headResponse.contentLength, 256);
|
|
expect(await headResponse.expand((e) => e).toList(), isEmpty);
|
|
final suffix = await client.getUrl(Uri.parse(url));
|
|
suffix.headers.set('range', 'bytes=-3');
|
|
final suffixResponse = await suffix.close();
|
|
expect(await suffixResponse.expand((e) => e).toList(), [253, 254, 255]);
|
|
await expectLater(
|
|
service.list(
|
|
connection(
|
|
'smb://127.0.0.1:$port/MUSIC/',
|
|
protocol: NetworkProtocol.smb,
|
|
user: 'test',
|
|
password: 'incorrect',
|
|
),
|
|
),
|
|
throwsA(
|
|
predicate<Object>(
|
|
(error) =>
|
|
classifyNetworkStorageError(error) ==
|
|
NetworkStorageError.authentication,
|
|
),
|
|
),
|
|
);
|
|
expect((await service.list(c)).any((e) => e.name == 'A B.wav'), true);
|
|
}, skip: Platform.environment['TEST_SMB'] != '1');
|
|
test('SMB guest works when credentials are explicitly blank', () async {
|
|
debugLibSmb2PathOverride = Platform.environment['TEST_SMB_LIBRARY'];
|
|
final port = Platform.environment['TEST_SMB_GUEST_PORT']!;
|
|
final service = NetworkStorageService(
|
|
read: () async => null,
|
|
write: (_) async {},
|
|
);
|
|
try {
|
|
final guest = connection(
|
|
'smb://127.0.0.1:$port/MUSIC/',
|
|
protocol: NetworkProtocol.smb,
|
|
);
|
|
expect((await service.list(guest)).any((e) => e.name == 'A B.wav'), true);
|
|
} finally {
|
|
debugLibSmb2PathOverride = null;
|
|
await service.dispose();
|
|
}
|
|
}, skip: Platform.environment['TEST_SMB_GUEST_PORT'] == null);
|
|
}
|