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(); final cache = Completer(); 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())); 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(); 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()), reason: invalid, ); await expectLater( ApiService().setBaseUrl(invalid), throwsA(isA()), 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); }, ); }