455 lines
15 KiB
Dart
455 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:reelbox/data/api_service.dart';
|
|
|
|
class StubAdapter implements HttpClientAdapter {
|
|
final FutureOr<ResponseBody> Function(RequestOptions) handler;
|
|
final requests = <RequestOptions>[];
|
|
final bodies = <String>[];
|
|
StubAdapter(this.handler);
|
|
@override
|
|
Future<ResponseBody> fetch(
|
|
RequestOptions options,
|
|
Stream<Uint8List>? body,
|
|
Future<void>? cancelFuture,
|
|
) async {
|
|
requests.add(options);
|
|
if (body != null) {
|
|
bodies.add(utf8.decode(await body.expand((chunk) => chunk).toList()));
|
|
}
|
|
return handler(options);
|
|
}
|
|
|
|
@override
|
|
void close({bool force = false}) {}
|
|
}
|
|
|
|
ResponseBody jsonBody(dynamic data, {int status = 200}) =>
|
|
ResponseBody.fromString(
|
|
jsonEncode(data),
|
|
status,
|
|
headers: {
|
|
Headers.contentTypeHeader: ['application/json'],
|
|
},
|
|
);
|
|
|
|
void main() {
|
|
test(
|
|
'observed search IDs take precedence over unrelated posted_id values',
|
|
() {
|
|
final api = ApiService();
|
|
final raw = jsonDecode(
|
|
File('test/fixtures/search.json').readAsStringSync(),
|
|
);
|
|
final titles = api.decodeTitles(raw);
|
|
expect(titles.map((title) => title.id).toList(), [32789, 18602]);
|
|
expect(titles.first.year, 2026);
|
|
expect(titles.first.imdb, 10);
|
|
expect(titles.first.raw['posted_id'], '2324949');
|
|
expect(titles.first.image, raw[0]['image']);
|
|
for (final key in ['posters', 'data', 'result', 'items']) {
|
|
expect(api.decodeTitles({key: raw}).map((title) => title.id), [
|
|
32789,
|
|
18602,
|
|
]);
|
|
}
|
|
expect(api.decodeTitles(null), isEmpty);
|
|
expect(
|
|
api.decodeTitles([
|
|
null,
|
|
12,
|
|
{},
|
|
{'id': '1', 'title': ''},
|
|
]),
|
|
isEmpty,
|
|
);
|
|
expect(
|
|
() => api.decodeTitles({'unexpected': 'shape'}),
|
|
throwsA(isA<ApiException>()),
|
|
);
|
|
},
|
|
);
|
|
test('search, recommendation, trending and actors use observed queries; removed public lists never request', () async {
|
|
final raw = jsonDecode(
|
|
File('test/fixtures/search.json').readAsStringSync(),
|
|
);
|
|
final adapter = StubAdapter((request) {
|
|
if (request.uri.queryParameters['action'] == 'search-trand') {
|
|
return jsonBody([
|
|
'Avatar',
|
|
{'title': 'Dune'},
|
|
{'q': 'Lost'},
|
|
null,
|
|
{},
|
|
]);
|
|
}
|
|
return jsonBody(raw);
|
|
});
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
expect((await api.search('A & ب', page: 2)).first.id, 32789);
|
|
await api.search('Avatar', english: true);
|
|
await api.recommended(3, page: 2);
|
|
await api.bestRandom();
|
|
await api.bestRandom(byYear: true, page: 3);
|
|
expect(await api.trending(), ['Avatar', 'Dune', 'Lost']);
|
|
await api.actors(page: 2);
|
|
await api.actors(search: ' Tom & ب ', page: 3);
|
|
expect(await api.playlists(page: 2), isEmpty);
|
|
expect(await api.playlistItems(3328, page: 3), isEmpty);
|
|
expect(adapter.requests.map((r) => r.uri.path).toList(), [
|
|
'/api-user/newapi/like.php',
|
|
'/api-user/newapi/like.php',
|
|
'/api-user/newapi/filter.php',
|
|
'/api-user/newapi/like.php',
|
|
'/api-user/newapi/like.php',
|
|
'/api-user/newapi/like.php',
|
|
'/api-user/newapi/like.php',
|
|
'/api-user/filmahang/star.php',
|
|
]);
|
|
expect(adapter.requests.map((r) => r.uri.queryParameters).toList(), [
|
|
{'action': 'search-movie', 'q': 'A & ب', 'pageno': '2'},
|
|
{'action': 'search-movie-en', 'q': 'Avatar', 'pageno': '1'},
|
|
{'action': 'pishnahad', 'garid': '3', 'pageno': '2'},
|
|
{'action': 'best-rand-top', 'pageno': '1'},
|
|
{'action': 'best-rand-year', 'pageno': '3'},
|
|
{'action': 'search-trand', 'pageno': '1'},
|
|
{'action': 'all-actor', 'pageno': '2'},
|
|
{'action': 'starsearch', 'q': 'Tom & ب', 'pageno': '3'},
|
|
]);
|
|
expect(
|
|
adapter.requests.every((request) => request.method == 'GET'),
|
|
isTrue,
|
|
);
|
|
await expectLater(
|
|
api.playlists(mine: true, userId: '1'),
|
|
throwsA(isA<ApiException>()),
|
|
);
|
|
expect(adapter.requests, hasLength(8));
|
|
});
|
|
test('real curated parallel arrays flatten and rewrite allowlisted legacy links safely', () async {
|
|
final raw = jsonDecode(
|
|
File('test/fixtures/curated.json').readAsStringSync(),
|
|
);
|
|
final adapter = StubAdapter(
|
|
(request) => request.uri.path.endsWith('filter.php')
|
|
? jsonBody(
|
|
jsonDecode(File('test/fixtures/search.json').readAsStringSync()),
|
|
)
|
|
: jsonBody(raw),
|
|
);
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
final links = await api.curatedLinks();
|
|
expect(links.single['title'], 'ترسناک');
|
|
expect(
|
|
links.single['link'],
|
|
'https://reelbox.dartty.top/api-user/newapi/filter.php?action=pishnahad&garid=1&pageno=',
|
|
);
|
|
await api.curatedLinks(best: true);
|
|
final titles = await api.curatedContent(
|
|
links.single['link'] as String,
|
|
page: 3,
|
|
);
|
|
expect(titles.first.id, 32789);
|
|
expect(adapter.requests[0].uri.path, '/api-user/newapi/pishnahad.php');
|
|
expect(adapter.requests[1].uri.path, '/api-user/newapi/bartar.php');
|
|
expect(adapter.requests[2].uri.queryParameters['pageno'], '3');
|
|
expect(adapter.requests[2].uri.queryParametersAll['pageno'], ['3']);
|
|
await expectLater(
|
|
api.curatedContent('https://evil.example/api-user/newapi/filter.php'),
|
|
throwsA(isA<ApiException>()),
|
|
);
|
|
});
|
|
test(
|
|
'public catalog endpoints use public tokens and preserve zero-based pages',
|
|
() async {
|
|
final movie = jsonDecode(
|
|
File('test/fixtures/movie.json').readAsStringSync(),
|
|
);
|
|
final season = jsonDecode(
|
|
File('test/fixtures/season.json').readAsStringSync(),
|
|
);
|
|
final adapter = StubAdapter((request) {
|
|
final path = request.uri.path;
|
|
if (path.contains('/first/')) {
|
|
return jsonBody({
|
|
'posters': [movie],
|
|
'genres': [],
|
|
});
|
|
}
|
|
if (path.contains('/all/')) {
|
|
return jsonBody([
|
|
{'id': 3, 'title': 'انیمیشن + انیمه'},
|
|
]);
|
|
}
|
|
if (path.contains('/season/')) return jsonBody([season]);
|
|
if (path.contains('/movie/by/35721/')) return jsonBody(movie);
|
|
if (path.contains('/channel/by/17/')) {
|
|
return jsonBody({...movie, 'id': 17, 'type': 'channel'});
|
|
}
|
|
return jsonBody([movie]);
|
|
});
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
expect((await api.home())['posters'][0]['id'], 35721);
|
|
expect((await api.genres()).single.id, 3);
|
|
expect((await api.countries()).single.id, 3);
|
|
expect(await api.categories(), isEmpty);
|
|
expect((await api.catalog()).single.id, 35721);
|
|
await api.catalog(kind: 'serie', genre: 3, order: 'imdb', page: 2);
|
|
await api.catalog(
|
|
kind: 'mixed',
|
|
genre: 2,
|
|
country: 9,
|
|
order: 'year',
|
|
page: 1,
|
|
);
|
|
await api.catalog(country: 9);
|
|
expect(await api.channels(category: 17, country: 1, page: 2), isEmpty);
|
|
expect((await api.poster(35721)).id, 35721);
|
|
await expectLater(api.channel(17), throwsA(isA<ApiException>()));
|
|
expect((await api.seasons(1003)).single.episodes.first.id, 72819);
|
|
await api.actorTitles(582);
|
|
await api.roles(35721);
|
|
await api.subtitles('movie', 35721);
|
|
await api.subtitles('episode', 72819);
|
|
await api.comments(35721);
|
|
const k = 'public';
|
|
expect(adapter.requests.map((r) => r.uri.path).toList(), [
|
|
'/api/first/$k/',
|
|
'/api/genre/all/$k/',
|
|
'/api/country/all/$k/',
|
|
'/api/movie/by/filtres/0/created/0/$k/',
|
|
'/api/serie/by/filtres/3/imdb/2/$k/',
|
|
'/api/poster/by/filtres/2/9/year/1/$k/',
|
|
'/api/poster/by/filtres/0/9/created/0/$k/',
|
|
'/api/movie/by/35721/$k/',
|
|
'/api/season/by/serie/1003/$k/',
|
|
'/api/movie/by/actor/582/$k/',
|
|
'/api/role/by/poster/35721/$k/',
|
|
'/api/subtitles/by/movie/35721/$k/',
|
|
'/api/subtitles/by/episode/72819/$k/',
|
|
]);
|
|
},
|
|
);
|
|
test(
|
|
'invalid catalog kinds and subtitle kinds cannot inject routes',
|
|
() async {
|
|
final adapter = StubAdapter((_) => jsonBody([]));
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
await expectLater(
|
|
api.catalog(kind: '../user'),
|
|
throwsA(isA<ApiException>()),
|
|
);
|
|
await expectLater(
|
|
api.subtitles('../user', 1),
|
|
throwsA(isA<ApiException>()),
|
|
);
|
|
expect(adapter.requests, isEmpty);
|
|
},
|
|
);
|
|
test(
|
|
'rejects untrusted, insecure and ambiguous routes before transport',
|
|
() async {
|
|
final adapter = StubAdapter((_) => jsonBody([]));
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
for (final path in [
|
|
'https://attacker.example/api/',
|
|
'http://ninwinios.info/api/first/',
|
|
'//attacker.example/api/',
|
|
'https://ninwinios.info@attacker.example/api/',
|
|
'https://user:secret@ninwinios.info/api/',
|
|
'https://ninwinios.info:444/api/',
|
|
'../private',
|
|
'/api/../private',
|
|
'/api/%2e%2e/private',
|
|
r'\evil\api',
|
|
'genre/all/#secret',
|
|
'file:///api/first',
|
|
]) {
|
|
await expectLater(
|
|
api.get(path),
|
|
throwsA(isA<ApiException>()),
|
|
reason: path,
|
|
);
|
|
}
|
|
expect(adapter.requests, isEmpty);
|
|
},
|
|
);
|
|
test(
|
|
'rewrites only allowlisted legacy public links to HTTPS current host',
|
|
() async {
|
|
final adapter = StubAdapter((_) => jsonBody([]));
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
await api.get(
|
|
'http://simbaapp.info/api-user/newapi/filter.php?action=best&x=a%2Fb',
|
|
);
|
|
expect(
|
|
adapter.requests.single.uri.toString(),
|
|
'https://reelbox.dartty.top/api-user/newapi/filter.php?action=best&x=a%2Fb',
|
|
);
|
|
},
|
|
);
|
|
test(
|
|
'base updates are atomic, allow HTTPS service and local HTTP tests only',
|
|
() async {
|
|
final api = ApiService();
|
|
for (final url in [
|
|
'http://ninwinios.info/api/',
|
|
'https://attacker.example/api/',
|
|
'https://user:secret@ninwinios.info/api/',
|
|
'https://ninwinios.info/other/',
|
|
]) {
|
|
await expectLater(api.setBaseUrl(url), throwsA(isA<ApiException>()));
|
|
expect(api.baseUrl, ApiService.defaultBaseUrl);
|
|
}
|
|
await api.setBaseUrl('http://127.0.0.1:8080/api');
|
|
expect(api.baseUrl, 'http://127.0.0.1:8080/api/');
|
|
expect(
|
|
() => ApiService(baseUrl: 'http://evil.example/api/'),
|
|
throwsA(isA<ApiException>()),
|
|
);
|
|
},
|
|
);
|
|
test(
|
|
'generic POST is form encoded and never follows credential redirects',
|
|
() async {
|
|
final adapter = StubAdapter((request) {
|
|
expect(request.followRedirects, isFalse);
|
|
expect(request.maxRedirects, 0);
|
|
return ResponseBody.fromString(
|
|
'',
|
|
307,
|
|
headers: {
|
|
'location': ['https://evil.example/collect'],
|
|
},
|
|
);
|
|
});
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
try {
|
|
await api.post('test-only', {
|
|
'password': 'never-log-me',
|
|
'comment': 'a & ب',
|
|
});
|
|
fail('Redirect must not succeed');
|
|
} on ApiException catch (error) {
|
|
expect(error.statusCode, 307);
|
|
expect(error.toString(), isNot(contains('never-log-me')));
|
|
expect(error.toString(), isNot(contains('test-only')));
|
|
}
|
|
expect(adapter.requests, hasLength(1));
|
|
expect(
|
|
adapter.requests.single.contentType,
|
|
Headers.formUrlEncodedContentType,
|
|
);
|
|
expect(Uri.splitQueryString(adapter.bodies.single), {
|
|
'password': 'never-log-me',
|
|
'comment': 'a & ب',
|
|
});
|
|
},
|
|
);
|
|
test(
|
|
'removed discovery ignores advertisements and inherits no credentials',
|
|
() async {
|
|
for (final candidate in [
|
|
'http://ninwinios.info/api/',
|
|
'https://attacker.example/api/',
|
|
]) {
|
|
final adapter = StubAdapter((request) {
|
|
if (request.uri.host == 'ninnies-server.info') {
|
|
return ResponseBody.fromString(candidate, 200);
|
|
}
|
|
return jsonBody([
|
|
{'id': 1, 'title': 'ترسناک'},
|
|
]);
|
|
});
|
|
final dio = Dio(BaseOptions(headers: {'Authorization': 'secret'}))
|
|
..httpClientAdapter = adapter;
|
|
final api = ApiService(dio: dio);
|
|
await api.initialize();
|
|
expect(api.baseUrl, ApiService.defaultBaseUrl);
|
|
expect(adapter.requests, isEmpty);
|
|
await api.genres();
|
|
expect(
|
|
adapter.requests.first.uri.toString(),
|
|
'${ApiService.iranBaseUrl}genre/all/public/',
|
|
);
|
|
expect(
|
|
adapter.requests.every(
|
|
(request) => !request.headers.containsKey('Authorization'),
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
adapter.requests.any(
|
|
(request) => request.uri.host == 'attacker.example',
|
|
),
|
|
isFalse,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
test(
|
|
'HTML, malformed JSON, timeout and HTTP denials produce safe errors',
|
|
() async {
|
|
for (final status in [200, 401, 403, 500]) {
|
|
final adapter = StubAdapter(
|
|
(_) =>
|
|
ResponseBody.fromString('<html>private details</html>', status),
|
|
);
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
await expectLater(
|
|
api.get('private/password-secret'),
|
|
throwsA(
|
|
isA<ApiException>().having(
|
|
(error) => error.toString(),
|
|
'sanitized message',
|
|
isNot(contains('password-secret')),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final adapter = StubAdapter(
|
|
(request) => throw DioException.connectionTimeout(
|
|
timeout: const Duration(seconds: 1),
|
|
requestOptions: request,
|
|
),
|
|
);
|
|
await expectLater(
|
|
ApiService(dio: Dio()..httpClientAdapter = adapter).get('secret'),
|
|
throwsA(isA<ApiException>()),
|
|
);
|
|
},
|
|
);
|
|
test(
|
|
'HTTPS relative route uses timeouts, public marker and JSON decoding',
|
|
() async {
|
|
final adapter = StubAdapter(
|
|
(_) => jsonBody([
|
|
{'id': 1, 'title': 'ترسناک'},
|
|
]),
|
|
);
|
|
final dio = Dio()..httpClientAdapter = adapter;
|
|
final api = ApiService(dio: dio);
|
|
await api.initialize(discover: false);
|
|
final result = await api.get('genre/all/${ApiService.sharedKey}/');
|
|
expect(result[0]['id'], 1);
|
|
expect(ApiService.sharedKey, 'public');
|
|
expect(api.baseUrl, ApiService.iranBaseUrl);
|
|
expect(api.hostRoot, 'https://reelbox.dartty.top');
|
|
final request = adapter.requests.single;
|
|
expect(
|
|
request.uri.toString(),
|
|
'${api.baseUrl}genre/all/${ApiService.sharedKey}/',
|
|
);
|
|
expect(request.connectTimeout, isNotNull);
|
|
expect(request.receiveTimeout, isNotNull);
|
|
expect(request.sendTimeout, isNotNull);
|
|
expect(request.followRedirects, isFalse);
|
|
},
|
|
);
|
|
}
|