fix(runtime): harden application service boundaries
This commit is contained in:
@@ -28,4 +28,39 @@ void main() {
|
||||
expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine/user-b'), 'jf-machine/user-b');
|
||||
});
|
||||
});
|
||||
group('Plex profile scopes', () {
|
||||
final plexServerId = ServerId('plex-machine');
|
||||
|
||||
test('are typed, deterministic, profile-specific, and publicly projected', () {
|
||||
final profileA = buildPlexProfileScopeId(serverId: plexServerId, profileId: 'profile-a');
|
||||
final profileB = buildPlexProfileScopeId(serverId: plexServerId, profileId: 'profile-b');
|
||||
|
||||
expect(profileA, buildPlexProfileScopeId(serverId: plexServerId, profileId: 'profile-a'));
|
||||
expect(profileA, isNot(profileB));
|
||||
expect(profileA.publicServerId, plexServerId);
|
||||
expect(profileA.profileId, 'profile-a');
|
||||
expect(profileA.cacheServerId, ServerId(profileA));
|
||||
expect(publicPlexServerIdFromScope(profileA), plexServerId);
|
||||
expect(resolveActiveClientScopeId(serverId: plexServerId, cacheServerId: profileA), profileA);
|
||||
});
|
||||
|
||||
test('encodes profile ids and cannot be interpreted as Jellyfin scope', () {
|
||||
final scope = buildPlexProfileScopeId(serverId: plexServerId, profileId: 'profile/a');
|
||||
|
||||
expect(scope.profileId, 'profile/a');
|
||||
expect(isPlexProfileScopeId(scope), isTrue);
|
||||
expect(isJellyfinUserScopeId(serverId: plexServerId, cacheServerId: scope), isFalse);
|
||||
expect(publicPlexServerIdFromScope('plex-machine/user-a'), isNull);
|
||||
});
|
||||
|
||||
test('rejects malformed persisted server prefixes before getters can throw', () {
|
||||
const malformed = ' /~plex-profile/profile-a';
|
||||
|
||||
final scope = PlexProfileScopeId.tryParse(malformed);
|
||||
|
||||
expect(scope, isNull);
|
||||
expect(publicPlexServerIdFromScope(malformed), isNull);
|
||||
expect(isPlexProfileScopeId(malformed), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:plezy/utils/app_logger.dart';
|
||||
import 'package:plezy/utils/log_redaction_manager.dart';
|
||||
|
||||
void main() {
|
||||
late MemoryAwareLogPrinter printer;
|
||||
|
||||
setUp(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
printer = MemoryAwareLogPrinter(SimplePrinter());
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
});
|
||||
|
||||
test('redacts message, error, and stack trace before storage and rendering', () {
|
||||
const messageSecret = 'message.value-_/+~==';
|
||||
const errorSecret = 'error.value-_/+~==';
|
||||
const stackSecret = 'stack.value-_/+~==';
|
||||
const passwordSecret = 'password.value-_/+~==';
|
||||
final stackTrace = StackTrace.fromString(
|
||||
'#0 connect (Authorization: Bearer $stackSecret)\n'
|
||||
'#1 retry (package:plezy/connect.dart:12:4)',
|
||||
);
|
||||
|
||||
final renderedLines = printer.log(
|
||||
LogEvent(
|
||||
Level.error,
|
||||
'connect operation Authorization: Bearer $messageSecret status=pending',
|
||||
error:
|
||||
'request failed Authorization=Basic $errorSecret '
|
||||
'{"password":"$passwordSecret","status":401}',
|
||||
stackTrace: stackTrace,
|
||||
),
|
||||
);
|
||||
|
||||
final rendered = renderedLines.join('\n');
|
||||
final stored = MemoryLogOutput.getLogs().single;
|
||||
final storedText = '${stored.message}\n${stored.error}\n${stored.stackTrace}';
|
||||
|
||||
for (final secret in [messageSecret, errorSecret, passwordSecret]) {
|
||||
expect(rendered, isNot(contains(secret)));
|
||||
}
|
||||
for (final secret in [messageSecret, errorSecret, passwordSecret, stackSecret]) {
|
||||
expect(storedText, isNot(contains(secret)));
|
||||
}
|
||||
expect(rendered, contains('connect operation'));
|
||||
expect(rendered, contains('status=pending'));
|
||||
expect(rendered, contains('request failed'));
|
||||
expect(rendered, contains('"status":401'));
|
||||
expect(storedText, contains('connect operation'));
|
||||
expect(storedText, contains('"status":401'));
|
||||
expect(storedText, contains('#1 retry'));
|
||||
expect(storedText, contains('#0 connect'));
|
||||
});
|
||||
|
||||
test('applies registered literal redaction to every logger field', () {
|
||||
const registeredToken = 'registered-token-sentinel';
|
||||
const registeredError = 'registered-error-sentinel';
|
||||
const registeredStack = 'registered-stack-sentinel';
|
||||
LogRedactionManager.registerToken(registeredToken);
|
||||
LogRedactionManager.registerCustomValue(registeredError);
|
||||
LogRedactionManager.registerCustomValue(registeredStack);
|
||||
|
||||
final rendered = MemoryAwareLogPrinter(_FieldRenderingPrinter())
|
||||
.log(
|
||||
LogEvent(
|
||||
Level.warning,
|
||||
'operation=refresh credential=$registeredToken status=starting',
|
||||
error: 'category=remote detail=$registeredError status=failed',
|
||||
stackTrace: StackTrace.fromString('#0 refresh $registeredStack\n#1 caller preserved'),
|
||||
),
|
||||
)
|
||||
.join('\n');
|
||||
final stored = MemoryLogOutput.getLogs().single;
|
||||
final storedText = '${stored.message}\n${stored.error}\n${stored.stackTrace}';
|
||||
|
||||
for (final secret in [registeredToken, registeredError, registeredStack]) {
|
||||
expect(rendered, isNot(contains(secret)));
|
||||
expect(storedText, isNot(contains(secret)));
|
||||
}
|
||||
expect(rendered, contains('operation=refresh'));
|
||||
expect(rendered, contains('status=failed'));
|
||||
expect(rendered, contains('#1 caller preserved'));
|
||||
});
|
||||
}
|
||||
|
||||
class _FieldRenderingPrinter extends LogPrinter {
|
||||
@override
|
||||
List<String> log(LogEvent event) {
|
||||
return ['${event.message}\n${event.error}\n${event.stackTrace}'];
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,23 @@ import 'dart:async';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/utils/app_logger.dart';
|
||||
import 'package:plezy/utils/endpoint_race.dart';
|
||||
import 'package:plezy/utils/log_redaction_manager.dart';
|
||||
|
||||
typedef _Result = ({String url, bool ok});
|
||||
|
||||
void main() {
|
||||
const headStart = Duration(milliseconds: 60);
|
||||
setUp(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
setLoggerLevel(true);
|
||||
});
|
||||
tearDown(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
});
|
||||
|
||||
Stream<EndpointRaceSelection<String, _Result>> race({
|
||||
required List<String> candidates,
|
||||
@@ -15,12 +26,14 @@ void main() {
|
||||
required Future<_Result> Function(String url) probe,
|
||||
Future<_Result> Function(String url)? measure,
|
||||
String? Function(Map<String, _Result> results)? selectBest,
|
||||
Map<String, Object?> Function(String candidate, _Result result)? failureLogFields,
|
||||
}) {
|
||||
return raceEndpointCandidates<String, _Result>(
|
||||
label: 'test',
|
||||
candidates: candidates,
|
||||
urlOf: (c) => c,
|
||||
preferredUrl: preferred,
|
||||
failureLogFields: failureLogFields,
|
||||
probe: (c, _) => probe(c),
|
||||
measure: measure ?? (c) async => (url: c, ok: false),
|
||||
isSuccess: (r) => r.ok,
|
||||
@@ -31,6 +44,47 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
test('preferred endpoint diagnostics contain no candidate literals', () async {
|
||||
const canary = 'https://preferred-race-canary.invalid/private-race-path';
|
||||
|
||||
final selections = await race(
|
||||
candidates: const [canary],
|
||||
preferred: canary,
|
||||
probe: (url) async => (url: url, ok: true),
|
||||
measure: (url) async => (url: url, ok: true),
|
||||
).toList();
|
||||
final storedFields = MemoryLogOutput.getLogs().expand<String>(
|
||||
(entry) => [entry.message, if (entry.error != null) entry.error.toString()],
|
||||
);
|
||||
|
||||
expect(selections.map((selection) => selection.candidate), everyElement(canary));
|
||||
for (final field in storedFields) {
|
||||
expect(field, isNot(contains('preferred-race-canary.invalid')));
|
||||
expect(field, isNot(contains('private-race-path')));
|
||||
}
|
||||
});
|
||||
|
||||
test('candidate failure diagnostics sanitize endpoint-bearing fields', () async {
|
||||
const canary = 'https://failure-race-canary.invalid/private-failure-path';
|
||||
|
||||
final selections = await race(
|
||||
candidates: const [canary],
|
||||
probe: (url) async => (url: url, ok: false),
|
||||
failureLogFields: (candidate, _) => {
|
||||
'error': 'probe failed at $candidate on failure-race-canary.invalid path /private-failure-path',
|
||||
},
|
||||
).toList();
|
||||
final storedFields = MemoryLogOutput.getLogs().expand<String>(
|
||||
(entry) => [entry.message, if (entry.error != null) entry.error.toString()],
|
||||
);
|
||||
|
||||
expect(selections, isEmpty);
|
||||
for (final field in storedFields) {
|
||||
expect(field, isNot(contains('failure-race-canary.invalid')));
|
||||
expect(field, isNot(contains('private-failure-path')));
|
||||
}
|
||||
});
|
||||
|
||||
test('healthy cached endpoint wins within the head start without racing', () {
|
||||
fakeAsync((async) {
|
||||
final probeCounts = <String, int>{};
|
||||
|
||||
@@ -5,16 +5,31 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
import 'package:plezy/utils/app_logger.dart';
|
||||
import 'package:plezy/utils/failover_http_client.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plezy/utils/log_redaction_manager.dart';
|
||||
|
||||
/// Pins the shared failover semantics both backends now ride on (see the
|
||||
/// class doc): GET-only single-step cascades, generation stamping, two-phase
|
||||
/// persistence, and exhaustion behavior. Backend-level coverage lives in
|
||||
/// jellyfin_client_failures_test.dart's failover group.
|
||||
void main() {
|
||||
setUp(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
setLoggerLevel(false);
|
||||
});
|
||||
tearDown(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
setLoggerLevel(true);
|
||||
});
|
||||
|
||||
const primary = 'https://primary.example.com';
|
||||
const fallback = 'https://fallback.example.com';
|
||||
|
||||
const tertiary = 'https://tertiary.example.com';
|
||||
http.Response ok([String id = 'ok']) =>
|
||||
http.Response(jsonEncode({'id': id}), 200, headers: {'content-type': 'application/json'});
|
||||
|
||||
@@ -22,6 +37,7 @@ void main() {
|
||||
build({
|
||||
required Future<http.Response> Function(http.Request request, List<Uri> seen) handler,
|
||||
List<String> endpoints = const [primary, fallback],
|
||||
Future<bool> Function(String candidateBaseUrl, AbortController? abort)? validateCandidate,
|
||||
}) {
|
||||
final switches = <({String url, bool persist})>[];
|
||||
final exhausted = <String>[];
|
||||
@@ -42,22 +58,29 @@ void main() {
|
||||
client.baseUrl = newBaseUrl;
|
||||
},
|
||||
onAllEndpointsExhausted: () => exhausted.add('x'),
|
||||
validateCandidate: validateCandidate,
|
||||
);
|
||||
addTearDown(client.close);
|
||||
return (client: client, switches: switches, exhausted: exhausted, requests: requests);
|
||||
}
|
||||
|
||||
test('transient failure switches once and persists the winner', () async {
|
||||
test('validated transient failover switches once and persists the winner', () async {
|
||||
final validations = <String>[];
|
||||
final h = build(
|
||||
handler: (request, _) async {
|
||||
if (request.url.host == 'primary.example.com') throw TimeoutException('down');
|
||||
return ok();
|
||||
},
|
||||
validateCandidate: (candidateBaseUrl, _) async {
|
||||
validations.add(candidateBaseUrl);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
final response = await h.client.get('/path');
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(validations, [fallback]);
|
||||
expect(h.requests.map((u) => u.host), ['primary.example.com', 'fallback.example.com']);
|
||||
expect(h.switches, [(url: fallback, persist: false), (url: fallback, persist: true)]);
|
||||
expect(h.exhausted, isEmpty);
|
||||
@@ -78,6 +101,99 @@ void main() {
|
||||
expect(h.switches.last.persist, isTrue);
|
||||
});
|
||||
|
||||
test('rejected candidate surfaces the original response without switching', () async {
|
||||
final h = build(
|
||||
handler: (request, _) async {
|
||||
expect(request.url.host, 'primary.example.com');
|
||||
return http.Response('primary unavailable', 503);
|
||||
},
|
||||
validateCandidate: (_, _) async => false,
|
||||
);
|
||||
|
||||
final response = await h.client.get('/path');
|
||||
|
||||
expect(response.statusCode, 503);
|
||||
expect(h.requests.map((uri) => uri.host), ['primary.example.com']);
|
||||
expect(h.switches, isEmpty);
|
||||
expect(h.exhausted, hasLength(1));
|
||||
expect(h.client.baseUrl, primary);
|
||||
});
|
||||
|
||||
test('rejected candidate is skipped before the single authenticated retry', () async {
|
||||
final validations = <String>[];
|
||||
final h = build(
|
||||
endpoints: const [primary, fallback, tertiary],
|
||||
handler: (request, _) async {
|
||||
if (request.url.host == 'primary.example.com') {
|
||||
return http.Response('primary unavailable', 503);
|
||||
}
|
||||
return ok(request.url.host);
|
||||
},
|
||||
validateCandidate: (candidateBaseUrl, _) async {
|
||||
validations.add(candidateBaseUrl);
|
||||
return candidateBaseUrl == tertiary;
|
||||
},
|
||||
);
|
||||
|
||||
final response = await h.client.get('/path');
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.data, {'id': 'tertiary.example.com'});
|
||||
expect(validations, [fallback, tertiary]);
|
||||
expect(h.requests.map((uri) => uri.host), ['primary.example.com', 'tertiary.example.com']);
|
||||
expect(h.switches, [(url: tertiary, persist: false), (url: tertiary, persist: true)]);
|
||||
expect(h.exhausted, isEmpty);
|
||||
expect(h.client.baseUrl, tertiary);
|
||||
});
|
||||
|
||||
test('throwing candidate validator surfaces the original transport failure', () async {
|
||||
final h = build(
|
||||
handler: (request, _) async {
|
||||
expect(request.url.host, 'primary.example.com');
|
||||
throw TimeoutException('primary unavailable');
|
||||
},
|
||||
validateCandidate: (_, _) async => throw StateError('probe failed'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
h.client.get('/path'),
|
||||
throwsA(isA<MediaServerHttpException>().having((error) => error.isTransient, 'isTransient', isTrue)),
|
||||
);
|
||||
|
||||
expect(h.requests.map((uri) => uri.host), ['primary.example.com']);
|
||||
expect(h.switches, isEmpty);
|
||||
expect(h.exhausted, hasLength(1));
|
||||
expect(h.client.baseUrl, primary);
|
||||
});
|
||||
|
||||
test('switch diagnostics contain no endpoint host or base-path literals', () async {
|
||||
const primaryCanary = 'https://primary-canary.invalid/private-primary-path';
|
||||
const fallbackCanary = 'https://fallback-canary.invalid/private-fallback-path';
|
||||
final h = build(
|
||||
endpoints: const [primaryCanary, fallbackCanary],
|
||||
handler: (request, _) async {
|
||||
if (request.url.host == 'primary-canary.invalid') throw TimeoutException('down');
|
||||
return ok();
|
||||
},
|
||||
);
|
||||
|
||||
final response = await h.client.get('/resource');
|
||||
final storedFields = MemoryLogOutput.getLogs().expand<String>(
|
||||
(entry) => [entry.message, if (entry.error != null) entry.error.toString()],
|
||||
);
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(h.requests.map((uri) => uri.host), ['primary-canary.invalid', 'fallback-canary.invalid']);
|
||||
expect(h.switches, [(url: fallbackCanary, persist: false), (url: fallbackCanary, persist: true)]);
|
||||
expect(h.client.baseUrl, fallbackCanary);
|
||||
for (final field in storedFields) {
|
||||
expect(field, isNot(contains('primary-canary.invalid')));
|
||||
expect(field, isNot(contains('private-primary-path')));
|
||||
expect(field, isNot(contains('fallback-canary.invalid')));
|
||||
expect(field, isNot(contains('private-fallback-path')));
|
||||
}
|
||||
});
|
||||
|
||||
test('4xx answers never fail over', () async {
|
||||
final h = build(handler: (request, _) async => http.Response('nope', 404));
|
||||
|
||||
@@ -174,8 +290,33 @@ void main() {
|
||||
expect(h.exhausted, isEmpty);
|
||||
});
|
||||
|
||||
test('rejected later candidate keeps the last accepted endpoint authoritative', () async {
|
||||
final validations = <String>[];
|
||||
final h = build(
|
||||
endpoints: const [primary, fallback, tertiary],
|
||||
handler: (request, _) async {
|
||||
expect(request.url.host, 'fallback.example.com');
|
||||
return http.Response('fallback unavailable', 503);
|
||||
},
|
||||
validateCandidate: (candidateBaseUrl, _) async {
|
||||
validations.add(candidateBaseUrl);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
h.client.resetEndpoints(const [primary, fallback, tertiary], currentBaseUrl: fallback);
|
||||
h.client.baseUrl = fallback;
|
||||
|
||||
expect((await h.client.get('/first')).statusCode, 503);
|
||||
expect((await h.client.get('/second')).statusCode, 503);
|
||||
|
||||
expect(validations, [tertiary, tertiary]);
|
||||
expect(h.requests.map((uri) => uri.host), ['fallback.example.com', 'fallback.example.com']);
|
||||
expect(h.switches, isEmpty);
|
||||
expect(h.client.baseUrl, fallback);
|
||||
expect(h.exhausted, hasLength(2));
|
||||
});
|
||||
|
||||
test('resetEndpoints replaces the cascade list', () async {
|
||||
const tertiary = 'https://tertiary.example.com';
|
||||
final h = build(
|
||||
handler: (request, _) async {
|
||||
if (request.url.host == 'tertiary.example.com') return ok();
|
||||
|
||||
@@ -2,7 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/models/livetv_channel.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/media/server_capabilities.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/video_player_screen.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/utils/live_tv_player_navigation.dart';
|
||||
@@ -41,6 +46,61 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
test('scoped Live TV selection fails closed and unscoped selection retains fallback', () {
|
||||
final aDvr = LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a');
|
||||
final aOtherDvr = LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-other');
|
||||
final bDvr = LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b');
|
||||
|
||||
multiServer.debugSetLiveTvServersForTesting([bDvr]);
|
||||
expect(
|
||||
liveTvServerInfoForChannel(
|
||||
multiServer,
|
||||
LiveTvChannel(key: 'channel-a', serverId: 'server-a', liveDvrKey: 'dvr-a'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
|
||||
multiServer.debugSetLiveTvServersForTesting([aOtherDvr, bDvr]);
|
||||
expect(
|
||||
liveTvServerInfoForChannel(
|
||||
multiServer,
|
||||
LiveTvChannel(key: 'channel-a', serverId: 'server-a', liveDvrKey: 'dvr-a'),
|
||||
),
|
||||
isNull,
|
||||
reason: 'an explicit DVR must not relax to another DVR on the same server',
|
||||
);
|
||||
expect(
|
||||
liveTvServerInfoForChannel(multiServer, LiveTvChannel(key: 'channel-a', serverId: 'server-a')),
|
||||
same(aOtherDvr),
|
||||
);
|
||||
|
||||
multiServer.debugSetLiveTvServersForTesting([bDvr, aDvr]);
|
||||
expect(
|
||||
liveTvServerInfoForChannel(
|
||||
multiServer,
|
||||
LiveTvChannel(key: 'channel-a', serverId: 'server-a', liveDvrKey: 'dvr-a'),
|
||||
),
|
||||
same(aDvr),
|
||||
);
|
||||
expect(liveTvServerInfoForChannel(multiServer, LiveTvChannel(key: 'legacy-channel')), same(bDvr));
|
||||
|
||||
multiServer.debugSetLiveTvServersForTesting(const []);
|
||||
expect(liveTvServerInfoForChannel(multiServer, LiveTvChannel(key: 'legacy-channel')), isNull);
|
||||
});
|
||||
|
||||
testWidgets('missing scoped server is not replaced by an online server', (tester) async {
|
||||
manager.debugRegisterClientForTesting(_TestClient(ServerId('server-b')));
|
||||
multiServer.debugSetLiveTvServersForTesting([LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b')]);
|
||||
final channel = LiveTvChannel(key: 'channel-a', title: 'Channel A', serverId: 'server-a', liveDvrKey: 'dvr-a');
|
||||
await pumpLauncher(tester, channel);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(VideoPlayerScreen), findsNothing);
|
||||
expect(find.text('Сървърът за телевизия на живо не е наличен.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('unavailable Live TV server error uses the active locale', (tester) async {
|
||||
final channel = LiveTvChannel(key: 'channel-1', title: 'Channel');
|
||||
await pumpLauncher(tester, channel);
|
||||
@@ -65,3 +125,25 @@ void main() {
|
||||
expect(find.text('Live TV server is not connected.'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
class _TestClient implements MediaServerClient {
|
||||
_TestClient(this.serverId);
|
||||
|
||||
@override
|
||||
final ServerId serverId;
|
||||
|
||||
@override
|
||||
String? get serverName => 'Test server';
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.jellyfin;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true);
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ void main() {
|
||||
test('api_key redaction is case-insensitive', () {
|
||||
final result = LogRedactionManager.redact('API_KEY=topsecret&z=1');
|
||||
expect(result.contains('topsecret'), isFalse);
|
||||
expect(result.contains('api_key=[REDACTED]'), isTrue);
|
||||
expect(result.contains('[REDACTED]'), isTrue);
|
||||
});
|
||||
|
||||
test('redacts Jellyfin Quick Connect secret query parameter without registration', () {
|
||||
@@ -56,7 +56,7 @@ void main() {
|
||||
test('Quick Connect secret redaction is case-insensitive and preserves other params', () {
|
||||
final result = LogRedactionManager.redact('SECRET=a%2Fb%20c&Authenticated=false');
|
||||
expect(result.contains('a%2Fb%20c'), isFalse);
|
||||
expect(result.contains('secret=[REDACTED]'), isTrue);
|
||||
expect(result.contains('[REDACTED]'), isTrue);
|
||||
expect(result.contains('Authenticated=false'), isTrue);
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ void main() {
|
||||
test('pin redaction is case-insensitive and leaves compound params intact', () {
|
||||
final result = LogRedactionManager.redact('PIN=0000&checkPin=abc&next=1');
|
||||
expect(result.contains('PIN=0000'), isFalse);
|
||||
expect(result.contains('pin=[REDACTED]'), isTrue);
|
||||
expect(result.contains('[REDACTED]'), isTrue);
|
||||
expect(result.contains('checkPin=abc'), isTrue);
|
||||
expect(result.contains('next=1'), isTrue);
|
||||
});
|
||||
@@ -108,6 +108,170 @@ void main() {
|
||||
final result = LogRedactionManager.redact('version 1.2.3 was released');
|
||||
expect(result, 'version 1.2.3 was released');
|
||||
});
|
||||
|
||||
test('redacts generic Authorization schemes across serialized forms', () {
|
||||
const cases = <({String input, String secret, String neighbor})>[
|
||||
(
|
||||
input: 'Authorization: Bearer bearer.value-_/+~==\nStatus: 401',
|
||||
secret: 'bearer.value-_/+~==',
|
||||
neighbor: 'Status: 401',
|
||||
),
|
||||
(
|
||||
input: 'aUtHoRiZaTiOn = Basic basic.value-_/+~==, status=denied',
|
||||
secret: 'basic.value-_/+~==',
|
||||
neighbor: 'status=denied',
|
||||
),
|
||||
(
|
||||
input: '{Authorization: Bearer map.value-_/+~==, operation: connect}',
|
||||
secret: 'map.value-_/+~==',
|
||||
neighbor: 'operation: connect',
|
||||
),
|
||||
(
|
||||
input: '{"authorization":"Basic json.value-_/+~==","status":"denied"}',
|
||||
secret: 'json.value-_/+~==',
|
||||
neighbor: '"status":"denied"',
|
||||
),
|
||||
(
|
||||
input: "{'Authorization' = 'Bearer quoted.value-_/+~=='; next=ok}",
|
||||
secret: 'quoted.value-_/+~==',
|
||||
neighbor: 'next=ok',
|
||||
),
|
||||
];
|
||||
|
||||
for (final testCase in cases) {
|
||||
final result = LogRedactionManager.redact(testCase.input);
|
||||
expect(result, isNot(contains(testCase.secret)), reason: testCase.input);
|
||||
expect(result, contains('[REDACTED]'), reason: testCase.input);
|
||||
expect(result, contains(testCase.neighbor), reason: testCase.input);
|
||||
}
|
||||
});
|
||||
|
||||
test('redacts opaque Authorization and multiple sensitive fields', () {
|
||||
const input =
|
||||
'Authorization: opaque-auth-value\n'
|
||||
'Proxy-Authorization: Basic proxy.value+/==\n'
|
||||
'{"password":"json-password","client_secret":"json-client-secret","status":"failed"}';
|
||||
|
||||
final result = LogRedactionManager.redact(input);
|
||||
|
||||
for (final secret in const ['opaque-auth-value', 'proxy.value+/==', 'json-password', 'json-client-secret']) {
|
||||
expect(result, isNot(contains(secret)));
|
||||
}
|
||||
expect('[REDACTED]'.allMatches(result).length, greaterThanOrEqualTo(4));
|
||||
expect(result, contains('"status":"failed"'));
|
||||
});
|
||||
|
||||
test('redacts exact sensitive query and header keys but preserves neighbors', () {
|
||||
const input =
|
||||
'GET /items?api_key=query-secret&refresh_token=refresh-secret&token_count=42\n'
|
||||
'Cookie: session=cookie-secret; refresh=second-cookie-secret\n'
|
||||
'X-Api-Key: header-secret\n'
|
||||
'Status: 403';
|
||||
|
||||
final result = LogRedactionManager.redact(input);
|
||||
|
||||
for (final secret in const [
|
||||
'query-secret',
|
||||
'refresh-secret',
|
||||
'cookie-secret',
|
||||
'second-cookie-secret',
|
||||
'header-secret',
|
||||
]) {
|
||||
expect(result, isNot(contains(secret)));
|
||||
}
|
||||
expect(result, contains('token_count=42'));
|
||||
expect(result, contains('Status: 403'));
|
||||
});
|
||||
|
||||
test('redacts complete unquoted structured values containing hashes', () {
|
||||
const cases = <({String input, String output, String secret})>[
|
||||
(input: '{password: left#right}', output: '{password: [REDACTED]}', secret: 'left#right'),
|
||||
(
|
||||
input: '{password: left"middle#right, status: denied}',
|
||||
output: '{password: [REDACTED], status: denied}',
|
||||
secret: 'left"middle#right',
|
||||
),
|
||||
(
|
||||
input: "{password: left'middle#right; status: denied}",
|
||||
output: '{password: [REDACTED]; status: denied}',
|
||||
secret: "left'middle#right",
|
||||
),
|
||||
(
|
||||
input: "request couldn't serialize {password: left{middle#right, status: denied}",
|
||||
output: "request couldn't serialize {password: [REDACTED], status: denied}",
|
||||
secret: 'left{middle#right',
|
||||
),
|
||||
(
|
||||
input: 'password: left#right # external comment',
|
||||
output: 'password: [REDACTED] # external comment',
|
||||
secret: 'left#right',
|
||||
),
|
||||
];
|
||||
|
||||
for (final testCase in cases) {
|
||||
final result = LogRedactionManager.redact(testCase.input);
|
||||
expect(result, testCase.output, reason: testCase.input);
|
||||
expect(result, isNot(contains(testCase.secret)), reason: testCase.input);
|
||||
}
|
||||
});
|
||||
|
||||
test('redacts nested structured values without consuming safe neighbors', () {
|
||||
const cases = <({String input, String output})>[
|
||||
(
|
||||
input: "{password: {primary: left#right, quoted: 'comma, hash# and } brace'}, status: denied}",
|
||||
output: '{password: [REDACTED], status: denied}',
|
||||
),
|
||||
(
|
||||
input: '{secret: [left#right, {"nested": "quote\\", comma, # and ] bracket"}], status: denied}',
|
||||
output: '{secret: [REDACTED], status: denied}',
|
||||
),
|
||||
(
|
||||
input: '{"password":"left,#right} still secret","status":"denied"}',
|
||||
output: '{"password":"[REDACTED]","status":"denied"}',
|
||||
),
|
||||
];
|
||||
|
||||
for (final testCase in cases) {
|
||||
expect(LogRedactionManager.redact(testCase.input), testCase.output, reason: testCase.input);
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves URL fragments and external comments', () {
|
||||
expect(
|
||||
LogRedactionManager.redact('GET https://example.test/path?password=left#public-fragment'),
|
||||
'GET https://example.test/path?password=[REDACTED]#public-fragment',
|
||||
);
|
||||
expect(
|
||||
LogRedactionManager.redact(
|
||||
'{"endpoint":"https://example.test/{item}?password=left#public-fragment","status":"denied"}',
|
||||
),
|
||||
'{"endpoint":"https://example.test/{item}?password=[REDACTED]#public-fragment","status":"denied"}',
|
||||
);
|
||||
expect(
|
||||
LogRedactionManager.redact('password=left # configuration comment'),
|
||||
'password=[REDACTED] # configuration comment',
|
||||
);
|
||||
});
|
||||
|
||||
test('redacts URL userinfo while preserving the destination', () {
|
||||
const input = 'connect https://synthetic-user:synthetic-password@example.test:8443/library?mode=fast';
|
||||
|
||||
final result = LogRedactionManager.redact(input);
|
||||
|
||||
expect(result, isNot(contains('synthetic-user')));
|
||||
expect(result, isNot(contains('synthetic-password')));
|
||||
expect(result, contains('https://[REDACTED]@example.test:8443/library?mode=fast'));
|
||||
});
|
||||
|
||||
test('does not over-redact prose or token-count-style fields', () {
|
||||
const input =
|
||||
'authorization failed after token refresh; '
|
||||
'token_count=42 token-count=43 tokenCount=44 max_tokens=45 '
|
||||
'input_tokens=46 outputTokens=47 notsecret=visible '
|
||||
'authorizationMode=interactive';
|
||||
|
||||
expect(LogRedactionManager.redact(input), input);
|
||||
});
|
||||
});
|
||||
|
||||
group('registerToken', () {
|
||||
@@ -115,7 +279,7 @@ void main() {
|
||||
LogRedactionManager.registerToken('abc-secret-XYZ');
|
||||
final result = LogRedactionManager.redact('Authorization: Bearer abc-secret-XYZ');
|
||||
expect(result.contains('abc-secret-XYZ'), isFalse);
|
||||
expect(result.contains('[REDACTED_TOKEN]'), isTrue);
|
||||
expect(result.contains('[REDACTED]'), isTrue);
|
||||
});
|
||||
|
||||
test('redacts URL-encoded form of a token', () {
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_library.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/utils/provider_extensions.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
const _missingOwnerLibrary = MediaLibrary(
|
||||
id: '1',
|
||||
backend: MediaBackend.plex,
|
||||
title: 'Missing owner',
|
||||
kind: MediaKind.movie,
|
||||
serverId: 'server-a',
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(db);
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
testWidgets('optional media-client lookups return null without MultiServerProvider', (tester) async {
|
||||
late BuildContext capturedContext;
|
||||
|
||||
@@ -22,4 +54,90 @@ void main() {
|
||||
expect(capturedContext.tryGetMediaClientWithFallback(ServerId('server-1')), isNull);
|
||||
expect(capturedContext.tryGetPlexClientForServer(ServerId('server-1')), isNull);
|
||||
});
|
||||
|
||||
testWidgets('library-qualified helpers reject a missing owner instead of returning another online server', (
|
||||
tester,
|
||||
) async {
|
||||
final replacement = testPlexClient(serverId: ServerId('server-b'));
|
||||
final manager = MultiServerManager()..debugRegisterClientForTesting(replacement);
|
||||
final provider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
addTearDown(() {
|
||||
provider.dispose();
|
||||
manager.dispose();
|
||||
});
|
||||
final context = await _pumpContext(tester, provider);
|
||||
|
||||
expect(() => context.getPlexClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable);
|
||||
expect(() => context.getMediaClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable);
|
||||
});
|
||||
|
||||
testWidgets('unqualified libraries fail while explicitly named fallback helpers still select an online server', (
|
||||
tester,
|
||||
) async {
|
||||
final replacement = testPlexClient(serverId: ServerId('server-b'));
|
||||
final manager = MultiServerManager()..debugRegisterClientForTesting(replacement);
|
||||
final provider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
addTearDown(() {
|
||||
provider.dispose();
|
||||
manager.dispose();
|
||||
});
|
||||
final context = await _pumpContext(tester, provider);
|
||||
|
||||
for (final serverId in <String?>[null, ' ']) {
|
||||
final library = MediaLibrary(
|
||||
id: '1',
|
||||
backend: MediaBackend.plex,
|
||||
title: 'Unqualified',
|
||||
kind: MediaKind.movie,
|
||||
serverId: serverId,
|
||||
);
|
||||
expect(() => context.getPlexClientForLibrary(library), _throwsNoClientAvailable);
|
||||
expect(() => context.getMediaClientForLibrary(library), _throwsNoClientAvailable);
|
||||
}
|
||||
|
||||
expect(context.getPlexClientWithFallback(ServerId('server-a')), same(replacement));
|
||||
expect(context.getMediaClientWithFallback(ServerId('server-a')), same(replacement));
|
||||
expect(context.tryGetMediaClientWithFallback(ServerId('server-a')), same(replacement));
|
||||
});
|
||||
|
||||
testWidgets('library-qualified helpers return their registered owner even when it is marked offline', (tester) async {
|
||||
final owner = testPlexClient(serverId: ServerId('server-a'));
|
||||
final replacement = testPlexClient(serverId: ServerId('server-b'));
|
||||
final manager = MultiServerManager()
|
||||
..debugRegisterClientForTesting(owner, online: false)
|
||||
..debugRegisterClientForTesting(replacement);
|
||||
final provider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
addTearDown(() {
|
||||
provider.dispose();
|
||||
manager.dispose();
|
||||
});
|
||||
final context = await _pumpContext(tester, provider);
|
||||
|
||||
expect(context.getPlexClientForLibrary(_missingOwnerLibrary), same(owner));
|
||||
expect(context.getMediaClientForLibrary(_missingOwnerLibrary), same(owner));
|
||||
});
|
||||
}
|
||||
|
||||
final _throwsNoClientAvailable = throwsA(
|
||||
isA<Exception>().having((error) => error.toString(), 'message', 'Exception: ${t.errors.noClientAvailable}'),
|
||||
);
|
||||
|
||||
Future<BuildContext> _pumpContext(WidgetTester tester, MultiServerProvider provider) async {
|
||||
late BuildContext capturedContext;
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: ChangeNotifierProvider<MultiServerProvider>.value(
|
||||
value: provider,
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
capturedContext = context;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return capturedContext;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_version.dart';
|
||||
import 'package:plezy/models/transcode_quality_preset.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/utils/video_player_navigation.dart';
|
||||
|
||||
@@ -22,35 +24,108 @@ void main() {
|
||||
expect(route.reverseTransitionDuration, Duration.zero);
|
||||
});
|
||||
|
||||
test('in-flight video player navigation rejects duplicate requests', () {
|
||||
final guard = VideoPlayerNavigationInFlightGuard();
|
||||
final item = testMediaItem(
|
||||
id: 'episode_1',
|
||||
group('video player launch identity', () {
|
||||
final plexA = testMediaItem(
|
||||
id: '123',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode 1',
|
||||
serverId: 'server_1',
|
||||
title: 'Plex A',
|
||||
serverId: 'plex-a',
|
||||
);
|
||||
final plexB = testMediaItem(
|
||||
id: '123',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Plex B',
|
||||
serverId: 'plex-b',
|
||||
);
|
||||
final jellyfin = testMediaItem(
|
||||
id: '123',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Jellyfin',
|
||||
serverId: 'jellyfin-a',
|
||||
);
|
||||
|
||||
expect(
|
||||
guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
guard.tryStart(item, mediaIndex: 1, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false),
|
||||
isTrue,
|
||||
);
|
||||
VideoPlayerLaunchIdentity identity(
|
||||
MediaItem item, {
|
||||
int mediaIndex = 0,
|
||||
String? sourceId,
|
||||
TranscodeQualityPreset? quality,
|
||||
bool isOffline = false,
|
||||
VideoPlayerRouteKind routeKind = VideoPlayerRouteKind.vod,
|
||||
}) {
|
||||
return VideoPlayerLaunchIdentity(
|
||||
metadata: item,
|
||||
mediaIndex: mediaIndex,
|
||||
selectedMediaSourceId: sourceId,
|
||||
selectedQualityPreset: quality,
|
||||
isOffline: isOffline,
|
||||
routeKind: routeKind,
|
||||
);
|
||||
}
|
||||
|
||||
guard.finish(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false);
|
||||
test('in-flight guard scopes duplicates and releases only the exact target', () {
|
||||
final guard = VideoPlayerNavigationInFlightGuard();
|
||||
final targetA = identity(plexA);
|
||||
final targetB = identity(plexB);
|
||||
|
||||
expect(
|
||||
guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false),
|
||||
isTrue,
|
||||
);
|
||||
expect(guard.tryStart(targetA), isTrue);
|
||||
expect(guard.tryStart(targetA), isFalse);
|
||||
expect(guard.tryStart(targetB), isTrue);
|
||||
expect(guard.tryStart(identity(plexA, mediaIndex: 1)), isTrue);
|
||||
|
||||
guard.finish(targetA);
|
||||
|
||||
expect(guard.tryStart(targetA), isTrue);
|
||||
expect(guard.tryStart(targetB), isFalse);
|
||||
});
|
||||
|
||||
test('active guard blocks only the complete server-qualified route target', () {
|
||||
final guard = VideoPlayerActiveRouteGuard();
|
||||
final owner = Object();
|
||||
final target = identity(plexA);
|
||||
guard.activate(owner, target);
|
||||
|
||||
expect(guard.activeGlobalKey, 'plex-a:123');
|
||||
expect(guard.blocks(target), isTrue);
|
||||
expect(guard.blocks(identity(plexB)), isFalse);
|
||||
expect(guard.blocks(identity(jellyfin)), isFalse);
|
||||
expect(guard.blocks(identity(plexA, mediaIndex: 1)), isFalse);
|
||||
expect(guard.blocks(identity(plexA, sourceId: 'source-b')), isFalse);
|
||||
expect(guard.blocks(identity(plexA, quality: TranscodeQualityPreset.p720_4mbps)), isFalse);
|
||||
expect(guard.blocks(identity(plexA, isOffline: true)), isFalse);
|
||||
expect(guard.blocks(identity(plexA, routeKind: VideoPlayerRouteKind.liveTv)), isFalse);
|
||||
});
|
||||
|
||||
test('blank and null source IDs identify the same route target', () {
|
||||
expect(identity(plexA, sourceId: ''), identity(plexA));
|
||||
expect(identity(plexA, sourceId: ' '), identity(plexA));
|
||||
});
|
||||
|
||||
test('owner checks preserve a replacement and support exact rollback', () {
|
||||
final guard = VideoPlayerActiveRouteGuard();
|
||||
final ownerA = Object();
|
||||
final ownerB = Object();
|
||||
final initial = identity(plexA, sourceId: 'source-a');
|
||||
final replacement = identity(plexB, quality: TranscodeQualityPreset.p1080_8mbps);
|
||||
guard.activate(ownerA, initial);
|
||||
guard.activate(ownerB, replacement);
|
||||
|
||||
expect(guard.clear(ownerA), isFalse);
|
||||
expect(guard.update(ownerA, identity(jellyfin)), isFalse);
|
||||
expect(guard.blocks(replacement), isTrue);
|
||||
|
||||
final beforeReload = guard.identityFor(ownerB);
|
||||
final reloadTarget = identity(plexB, sourceId: 'source-b', quality: TranscodeQualityPreset.p720_4mbps);
|
||||
expect(guard.update(ownerB, reloadTarget), isTrue);
|
||||
expect(guard.blocks(reloadTarget), isTrue);
|
||||
expect(guard.update(ownerB, beforeReload!), isTrue);
|
||||
expect(guard.blocks(replacement), isTrue);
|
||||
|
||||
expect(guard.clear(ownerB), isTrue);
|
||||
expect(guard.activeGlobalKey, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('media version preference persistence', () {
|
||||
|
||||
Reference in New Issue
Block a user