Skip to content

Commit

Permalink
Merge pull request #2267 from nextcloud/fix/neon_framework/redirect-i…
Browse files Browse the repository at this point in the history
…ndex-php
  • Loading branch information
provokateurin authored Jul 16, 2024
2 parents bb102cb + 2ee663a commit b62709c
Show file tree
Hide file tree
Showing 3 changed files with 308 additions and 53 deletions.
116 changes: 63 additions & 53 deletions packages/neon_framework/lib/src/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,65 +36,75 @@ GoRouter buildAppRouter({
debugLogDiagnostics: kDebugMode,
navigatorKey: navigatorKey,
initialLocation: const HomeRoute().location,
onException: (context, state, router) async {
final accountsBloc = NeonProvider.of<AccountsBloc>(context);
if (accountsBloc.hasAccounts) {
var uri = state.uri;
final capabilitiesBloc = NeonProvider.of<CapabilitiesBloc>(context);
final capabilities = capabilitiesBloc.capabilities.valueOrNull?.data;
final modRewriteWorking = capabilities?.capabilities.coreCapabilities?.core.modRewriteWorking ?? false;
if (!modRewriteWorking) {
uri = state.uri.replace(path: '/index.php${state.uri}');
}

final account = NeonProvider.of<Account>(context);
await launchUrl(
account.completeUri(uri),
mode: LaunchMode.externalApplication,
);

return;
}
onException: onException,
redirect: redirect,
routes: $appRoutes,
);

if (!context.mounted) {
return;
}
/// Handles routing exceptions thrown by the [GoRouter] of [buildAppRouter].
@visibleForTesting
Future<void> onException(BuildContext context, GoRouterState state, GoRouter router) async {
final accountsBloc = NeonProvider.of<AccountsBloc>(context);
if (accountsBloc.hasAccounts) {
final capabilitiesBloc = NeonProvider.of<CapabilitiesBloc>(context);
final capabilities = capabilitiesBloc.capabilities.valueOrNull?.data;
final modRewriteWorking = capabilities?.capabilities.coreCapabilities?.core.modRewriteWorking ?? false;

await router.push(RouteNotFoundRoute(uri: state.uri).location);
},
redirect: (context, state) {
if (state.uri.path.startsWith('/index.php/')) {
return state.uri.path.substring(10);
}
final account = NeonProvider.of<Account>(context);
var uri = account.completeUri(state.uri);

final loginQRcode = LoginQRcode.tryParse(state.uri.toString());
if (loginQRcode != null) {
return LoginCheckServerStatusWithCredentialsRoute(
serverUrl: loginQRcode.serverURL,
loginName: loginQRcode.username,
password: loginQRcode.password,
).location;
}
if (uri != state.uri && !modRewriteWorking && !uri.path.startsWith('/index.php')) {
uri = uri.replace(path: '/index.php${uri.path}');
}

final accountsBloc = NeonProvider.of<AccountsBloc>(context);
if (accountsBloc.hasAccounts && state.uri.hasScheme) {
final account = NeonProvider.of<Account>(context);
final strippedUri = account.stripUri(state.uri);
if (strippedUri != state.uri) {
return strippedUri.toString();
}
}
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);

// Redirect to login screen when no account is logged in
// We only check the prefix of the current location as we also don't want to redirect on any of the other login routes.
if (!accountsBloc.hasAccounts && !state.matchedLocation.startsWith(const LoginRoute().location)) {
return const LoginRoute().location;
}
return;
}

return null;
},
routes: $appRoutes,
);
if (!context.mounted) {
return;
}

await router.push(RouteNotFoundRoute(uri: state.uri).location);
}

/// Handles redirects of the [GoRouter] of [buildAppRouter].
@visibleForTesting
String? redirect(BuildContext context, GoRouterState state) {
if (state.uri.path.startsWith('/index.php/')) {
return state.uri.path.substring(10);
}

final loginQRcode = LoginQRcode.tryParse(state.uri.toString());
if (loginQRcode != null) {
return LoginCheckServerStatusWithCredentialsRoute(
serverUrl: loginQRcode.serverURL,
loginName: loginQRcode.username,
password: loginQRcode.password,
).location;
}

final accountsBloc = NeonProvider.of<AccountsBloc>(context);
if (accountsBloc.hasAccounts && state.uri.hasScheme) {
final account = NeonProvider.of<Account>(context);
final strippedUri = account.stripUri(state.uri);
if (strippedUri != state.uri) {
return strippedUri.toString();
}
}

// Redirect to login screen when no account is logged in
// We only check the prefix of the current location as we also don't want to redirect on any of the other login routes.
if (!accountsBloc.hasAccounts && !state.matchedLocation.startsWith(const LoginRoute().location)) {
return const LoginRoute().location;
}

return null;
}

/// {@template AppRoutes.AccountSettingsRoute}
/// Route for the [AccountSettingsPage].
Expand Down
8 changes: 8 additions & 0 deletions packages/neon_framework/lib/src/testing/mocks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import 'package:neon_framework/src/storage/persistence.dart';
import 'package:neon_framework/src/utils/account_options.dart';
import 'package:neon_framework/storage.dart';
import 'package:nextcloud/provisioning_api.dart' as provisioning_api;
// ignore: depend_on_referenced_packages
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
// ignore: depend_on_referenced_packages
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';

class MockAccount extends Mock implements Account {
@internal
Expand Down Expand Up @@ -94,3 +98,7 @@ class MockUserDetails extends Mock implements provisioning_api.UserDetails {}
class MockSelectOption<T> extends Mock implements SelectOption<T> {}

class MockGoRouter extends Mock implements GoRouter {}

class MockGoRouterState extends Mock implements GoRouterState {}

class MockUrlLauncher extends Mock with MockPlatformInterfaceMixin implements UrlLauncherPlatform {}
237 changes: 237 additions & 0 deletions packages/neon_framework/test/router_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:neon_framework/blocs.dart';
import 'package:neon_framework/models.dart';
import 'package:neon_framework/src/blocs/accounts.dart';
import 'package:neon_framework/src/blocs/capabilities.dart';
import 'package:neon_framework/src/router.dart';
import 'package:neon_framework/testing.dart';
import 'package:neon_framework/utils.dart';
import 'package:nextcloud/core.dart' as core;
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
// ignore: depend_on_referenced_packages
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';

core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data buildCapabilities(
core.CoreCapabilities? capabilities,
) =>
core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data(
(b) => b
..version.update(
(b) => b
..major = 0
..minor = 0
..micro = 0
..string = ''
..edition = ''
..extendedSupport = false,
)
..capabilities = (
// We need to provide at least one capability because anyOf expects at least one schema to match
commentsCapabilities: null,
coreCapabilities: capabilities,
corePublicCapabilities: null,
davCapabilities: null,
dropAccountCapabilities: null,
filesCapabilities: null,
filesSharingCapabilities: null,
filesTrashbinCapabilities: null,
filesVersionsCapabilities: null,
notesCapabilities: null,
notificationsCapabilities: null,
provisioningApiCapabilities: null,
sharebymailCapabilities: null,
spreedCapabilities: null,
spreedPublicCapabilities: null,
systemtagsCapabilities: null,
themingPublicCapabilities: null,
userStatusCapabilities: null,
weatherStatusCapabilities: null,
),
);

void main() {
group('redirect', () {
testWidgets('Strip index.php', (tester) async {
await tester.pumpWidgetWithAccessibility(
TestApp(
child: Container(),
),
);
final context = tester.element(find.byType(Container));

final state = MockGoRouterState();
when(() => state.uri).thenReturn(Uri(path: '/index.php/test'));

expect(redirect(context, state), '/test');
});

testWidgets('QR-Code login', (tester) async {
await tester.pumpWidgetWithAccessibility(
TestApp(
child: Container(),
),
);
final context = tester.element(find.byType(Container));

final state = MockGoRouterState();
when(() => state.uri).thenReturn(Uri.parse('nc://login/user:JohnDoe&password:super_secret&server:example.com'));

expect(redirect(context, state), '/login/check/server/JohnDoe/super_secret?server-url=example.com');
});

testWidgets('Strip account', (tester) async {
final accountsBloc = MockAccountsBloc();
when(() => accountsBloc.hasAccounts).thenReturn(true);

final account = Account(
(b) => b
..serverURL = Uri.parse('http://example.com')
..username = '',
);

await tester.pumpWidgetWithAccessibility(
TestApp(
providers: [
NeonProvider<AccountsBloc>.value(value: accountsBloc),
Provider<Account>.value(value: account),
],
child: Container(),
),
);
final context = tester.element(find.byType(Container));

final state = MockGoRouterState();

when(() => state.uri).thenReturn(Uri.parse('http://example.com/test'));
expect(redirect(context, state), '/test');

when(() => state.uri).thenReturn(Uri.parse('example.com/test'));
expect(redirect(context, state), null);
});

testWidgets('Login', (tester) async {
final accountsBloc = MockAccountsBloc();
when(() => accountsBloc.hasAccounts).thenReturn(false);

await tester.pumpWidgetWithAccessibility(
TestApp(
providers: [
NeonProvider<AccountsBloc>.value(value: accountsBloc),
],
child: Container(),
),
);
final context = tester.element(find.byType(Container));

final state = MockGoRouterState();

when(() => state.uri).thenReturn(Uri.parse('/test'));
when(() => state.matchedLocation).thenReturn('/test');
expect(redirect(context, state), '/login');

when(() => state.uri).thenReturn(Uri.parse('/login/test'));
when(() => state.matchedLocation).thenReturn('/login/test');
expect(redirect(context, state), null);
});
});

group('onException', () {
testWidgets('Not found', (tester) async {
final accountsBloc = MockAccountsBloc();
when(() => accountsBloc.hasAccounts).thenReturn(false);

await tester.pumpWidgetWithAccessibility(
TestApp(
providers: [
NeonProvider<AccountsBloc>.value(value: accountsBloc),
],
child: Container(),
),
);
final context = tester.element(find.byType(Container));

final router = MockGoRouter();
when(() => router.push(any())).thenAnswer((_) => Future.value());

final state = MockGoRouterState();
when(() => state.uri).thenReturn(Uri(path: '/test'));

await onException(context, state, router);
verify(() => router.push('/not-found/${Uri.encodeComponent('/test')}')).called(1);
});

group('Complete account', () {
late MockUrlLauncher urlLauncher;

setUpAll(() {
registerFallbackValue(const LaunchOptions());

urlLauncher = MockUrlLauncher();
// ignore: discarded_futures
when(() => urlLauncher.launchUrl(any(), any())).thenAnswer((_) async => true);

UrlLauncherPlatform.instance = urlLauncher;
});

for (final (path, modRewriteWorking, url) in [
('/test', true, 'http://example.com/test'),
('/test', false, 'http://example.com/index.php/test'),
('http://example.org/test', false, 'http://example.org/test'),
]) {
testWidgets('Mod rewrite ${modRewriteWorking ? '' : 'not '}working $path', (tester) async {
final accountsBloc = MockAccountsBloc();
when(() => accountsBloc.hasAccounts).thenReturn(true);

final capabilitiesBloc = MockCapabilitiesBloc();
when(() => capabilitiesBloc.capabilities).thenAnswer(
(_) => BehaviorSubject.seeded(
Result.success(
buildCapabilities(
core.CoreCapabilities(
(b) => b.core.update(
(b) => b
..pollinterval = 0
..webdavRoot = ''
..referenceApi = false
..referenceRegex = ''
..modRewriteWorking = modRewriteWorking,
),
),
),
),
),
);

final account = Account(
(b) => b
..serverURL = Uri.parse('http://example.com')
..username = '',
);

await tester.pumpWidgetWithAccessibility(
TestApp(
providers: [
NeonProvider<AccountsBloc>.value(value: accountsBloc),
Provider<Account>.value(value: account),
NeonProvider<CapabilitiesBloc>.value(value: capabilitiesBloc),
],
child: Container(),
),
);
final context = tester.element(find.byType(Container));

final router = MockGoRouter();

final state = MockGoRouterState();
when(() => state.uri).thenReturn(Uri.parse(path));

await onException(context, state, router);
verify(() => urlLauncher.launchUrl(url, any())).called(1);
});
}
});
});
}

0 comments on commit b62709c

Please sign in to comment.