Files
SpotiFLAC-Mobile/test/network_storage_service_test.dart
T
zarzet e9b7e84113 fix(webdav): retain tracks across equivalent parent URI encodings
Match decoded path components once in Dart and Rust while rejecting encoded separators and paths outside the selected folder. Add folder scan diagnostics, shared parser fixtures, a synthetic 1806-track traversal and native Android regression coverage.
2026-10-10 23:22:21 +07:00

513 lines
18 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('WebDAV accepts equivalent encoding of parent folder names', () {
final server = connection(
'https://example.test/dav/',
protocol: NetworkProtocol.webdav,
);
for (final folder in [
'Rock & Roll',
'Artist + Band',
'Album [Deluxe]',
"Artist's Album",
'हिन्दी 音楽',
]) {
final href = '/dav/${Uri.encodeComponent(folder)}/A%20%26%20B.flac';
final entries = NetworkStorageService.parseListing(
server,
'$folder/',
'<d:multistatus xmlns:d="DAV:"><d:response><d:href>$href</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:multistatus>',
);
expect(
entries.map((entry) => entry.path),
['$folder/A & B.flac'],
reason:
'Requested ${networkTarget(server, '$folder/')} but href was $href',
);
}
});
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);
}