fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
+35
View File
@@ -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);
});
});
}
+97
View File
@@ -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}'];
}
}
+54
View File
@@ -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>{};
+143 -2
View File
@@ -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);
}
+168 -4
View File
@@ -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', () {
+118
View File
@@ -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;
}
+98 -23
View File
@@ -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', () {