219 lines
7.2 KiB
Dart
219 lines
7.2 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:reelbox/data/api_service.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'api_service_test.dart' as fixtures;
|
|
|
|
const iranBase = 'https://reelbox.dartty.top/api/';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
|
|
|
test(
|
|
'malicious persisted selections cannot redirect the first network request',
|
|
() async {
|
|
for (final invalid in [
|
|
'https://reelbox.dartty.top:8443/api/',
|
|
'http://reelbox.dartty.top:8443/api/',
|
|
'https://reelbox.dartty.top.evil.example:8443/api/',
|
|
'http://127.0.0.1:8123/api/',
|
|
'https://user@reelbox.dartty.top:8443/api/',
|
|
]) {
|
|
SharedPreferences.setMockInitialValues({
|
|
'reelbox.api-selection.v1': invalid,
|
|
});
|
|
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
await api.genres();
|
|
expect(api.baseUrl, ApiService.defaultBaseUrl, reason: invalid);
|
|
expect(
|
|
adapter.requests.single.uri.origin,
|
|
'https://reelbox.dartty.top',
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
test('explicit choice wins over delayed cache restoration', () async {
|
|
final cacheStarted = Completer<void>();
|
|
final cache = Completer<SharedPreferences>();
|
|
var reads = 0;
|
|
final api = ApiService(
|
|
preferences: () {
|
|
if (reads++ == 0) {
|
|
cacheStarted.complete();
|
|
return cache.future;
|
|
}
|
|
return SharedPreferences.getInstance();
|
|
},
|
|
);
|
|
final restoration = api.initialize(discover: false);
|
|
await cacheStarted.future;
|
|
await api.setBaseUrl(iranBase);
|
|
cache.complete(await SharedPreferences.getInstance());
|
|
await restoration;
|
|
expect(api.baseUrl, iranBase);
|
|
});
|
|
|
|
test('actor search restores the selected origin before building its absolute route', () async {
|
|
await ApiService().setBaseUrl(iranBase);
|
|
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
|
|
final restarted = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
await restarted.actors(search: 'Example');
|
|
expect(adapter.requests.single.uri.origin, 'https://reelbox.dartty.top');
|
|
});
|
|
|
|
test('Iran catalogue preserves media URLs and never falls back on failure', () async {
|
|
const media =
|
|
'https://original-media.example:9443/video.mp4?token=Exact%2FValue&x=1';
|
|
final adapter = fixtures.StubAdapter(
|
|
(_) => fixtures.jsonBody({
|
|
'id': 7,
|
|
'title': 'Movie',
|
|
'sources': [
|
|
{'id': 3, 'url': media, 'type': 'mp4'},
|
|
],
|
|
}),
|
|
);
|
|
final api = ApiService(
|
|
baseUrl: iranBase,
|
|
dio: Dio()..httpClientAdapter = adapter,
|
|
);
|
|
expect((await api.poster(7)).sources.single.url, media);
|
|
final offline = fixtures.StubAdapter(
|
|
(r) => throw DioException(
|
|
requestOptions: r,
|
|
type: DioExceptionType.connectionError,
|
|
),
|
|
);
|
|
final failing = ApiService(
|
|
baseUrl: iranBase,
|
|
dio: Dio()..httpClientAdapter = offline,
|
|
);
|
|
await expectLater(failing.genres(), throwsA(isA<ApiException>()));
|
|
expect(failing.baseUrl, iranBase);
|
|
expect(offline.requests, hasLength(2));
|
|
expect(
|
|
offline.requests.every(
|
|
(r) => r.uri.origin == 'https://reelbox.dartty.top',
|
|
),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'concurrent initialization shares migration without discovery or probes',
|
|
() async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'reelbox.api-selection.v1': 'https://ninwinios.info/api/',
|
|
});
|
|
final cache = Completer<SharedPreferences>();
|
|
var reads = 0;
|
|
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
|
|
final api = ApiService(
|
|
dio: Dio()..httpClientAdapter = adapter,
|
|
preferences: () {
|
|
reads++;
|
|
return cache.future;
|
|
},
|
|
);
|
|
final initialization = Future.wait([api.initialize(), api.initialize()]);
|
|
cache.complete(await SharedPreferences.getInstance());
|
|
await initialization;
|
|
expect(reads, 1);
|
|
expect(adapter.requests, isEmpty);
|
|
expect(api.baseUrl, iranBase);
|
|
final restarted = ApiService();
|
|
await restarted.initialize(discover: false);
|
|
expect(restarted.baseUrl, iranBase);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'unsigned advertisements are never fetched or used during initialization',
|
|
() async {
|
|
for (final advertised in [iranBase, 'http://ninwinios.info/api/']) {
|
|
final adapter = fixtures.StubAdapter(
|
|
(r) => r.uri.host == 'ninnies-server.info'
|
|
? ResponseBody.fromString(advertised, 200)
|
|
: fixtures.jsonBody([
|
|
{'id': 1, 'title': 'Drama'},
|
|
]),
|
|
);
|
|
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
await api.initialize();
|
|
expect(api.baseUrl, ApiService.defaultBaseUrl);
|
|
expect(adapter.requests, isEmpty);
|
|
}
|
|
},
|
|
);
|
|
|
|
test('only exact HTTPS service origins and constructor loopback fixtures are accepted', () async {
|
|
for (final invalid in [
|
|
'http://reelbox.dartty.top:8443/api/',
|
|
'https://reelbox.dartty.top:8443/api/',
|
|
'https://reelbox.dartty.top:8444/api/',
|
|
'https://reelbox.dartty.top.evil.example:8443/api/',
|
|
'https://evil.reelbox.dartty.top:8443/api/',
|
|
'https://ninwinios.info:8443/api/',
|
|
'https://ninwinios.info/api/',
|
|
'https://user@reelbox.dartty.top:8443/api/',
|
|
'https://reelbox.dartty.top/api/?q=1',
|
|
'https://reelbox.dartty.top/api/#fragment',
|
|
'https://reelbox.dartty.top/api/../api/',
|
|
' $iranBase',
|
|
]) {
|
|
expect(
|
|
() => ApiService(baseUrl: invalid),
|
|
throwsA(isA<ApiException>()),
|
|
reason: invalid,
|
|
);
|
|
await expectLater(
|
|
ApiService().setBaseUrl(invalid),
|
|
throwsA(isA<ApiException>()),
|
|
reason: invalid,
|
|
);
|
|
}
|
|
for (final base in [
|
|
'http://127.0.0.1:4589/api/',
|
|
'http://localhost:4589/api/',
|
|
'http://[::1]:4589/api/',
|
|
]) {
|
|
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
|
|
final api = ApiService(
|
|
baseUrl: base,
|
|
dio: Dio()..httpClientAdapter = adapter,
|
|
);
|
|
await api.genres();
|
|
expect(adapter.requests.single.uri.origin, Uri.parse(base).origin);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'explicit Iran choice persists and restores before first catalogue request',
|
|
() async {
|
|
final first = ApiService();
|
|
await first.setBaseUrl(iranBase);
|
|
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
|
|
final restarted = ApiService(dio: Dio()..httpClientAdapter = adapter);
|
|
await restarted.genres();
|
|
expect(restarted.baseUrl, iranBase);
|
|
expect(adapter.requests.single.uri.origin, 'https://reelbox.dartty.top');
|
|
await restarted.initialize();
|
|
expect(
|
|
adapter.requests,
|
|
hasLength(1),
|
|
reason: 'Iran must not run original discovery',
|
|
);
|
|
await restarted.setBaseUrl(ApiService.defaultBaseUrl);
|
|
final fresh = ApiService();
|
|
await fresh.initialize(discover: false);
|
|
expect(fresh.baseUrl, iranBase);
|
|
},
|
|
);
|
|
}
|