ForgotPasswordScreen with neutral reaction (F12), CheckEmail reset mode on its own path, recovery link handling and ResetPasswordScreen with the recovery guard held until the user confirms the success message. Carries the from parameter between login, register and forgot password. Removes the route placeholders. Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
368 lines
12 KiB
Dart
368 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
import 'package:shuffle/core/routing/app_router.dart';
|
|
import 'package:shuffle/features/auth/domain/auth_status.dart';
|
|
import 'package:shuffle/features/auth/domain/registration_input.dart';
|
|
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
|
|
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
|
|
|
|
import '../../../helpers/fakes.dart';
|
|
import '../../../helpers/pump_app.dart';
|
|
import '../../../helpers/router_helpers.dart';
|
|
import '../data/auth_test_support.dart';
|
|
import 'auth_ui_support.dart' show commonTestPasswords;
|
|
|
|
void main() {
|
|
late SupabaseMocks mocks;
|
|
late StreamController<AuthState> events;
|
|
late Session? session;
|
|
late int signedOutCalls;
|
|
|
|
setUp(() {
|
|
mocks = SupabaseMocks();
|
|
events = StreamController<AuthState>.broadcast(sync: true);
|
|
session = null;
|
|
signedOutCalls = 0;
|
|
when(() => mocks.auth.currentSession).thenAnswer((_) => session);
|
|
when(() => mocks.auth.onAuthStateChange).thenAnswer((_) => events.stream);
|
|
});
|
|
|
|
tearDown(() => events.close());
|
|
|
|
void emit(AuthChangeEvent event, {Session? withSession}) {
|
|
session = withSession;
|
|
events.add(AuthState(event, withSession));
|
|
}
|
|
|
|
Future<ProviderContainerAndRouter> start(WidgetTester tester) async {
|
|
final container = await pumpShuffleApp(
|
|
tester,
|
|
overrides: testOverrides(
|
|
supabase: mocks.client,
|
|
overrides: [
|
|
...authOverrides(),
|
|
// No asset loading in router tests.
|
|
commonPasswordListProvider.overrideWith(
|
|
(ref) => Future.value(commonTestPasswords),
|
|
),
|
|
privateRoutePathsProvider.overrideWithValue(const ['/private']),
|
|
signedOutHandlersProvider.overrideWithValue([
|
|
(ref) => signedOutCalls++,
|
|
]),
|
|
],
|
|
),
|
|
);
|
|
return (container: container, router: container.read(appRouterProvider));
|
|
}
|
|
|
|
group('KTO-01 Kriterium 5: authStatusProvider', () {
|
|
testWidgets('maps events and starts with the stored session', (
|
|
tester,
|
|
) async {
|
|
session = testSession();
|
|
final (:container, router: _) = await start(tester);
|
|
|
|
expect(container.read(authStatusProvider).value, isA<SignedIn>());
|
|
|
|
emit(AuthChangeEvent.signedOut);
|
|
await tester.pumpAndSettle();
|
|
expect(container.read(authStatusProvider).value, isA<SignedOut>());
|
|
|
|
emit(AuthChangeEvent.signedIn, withSession: testSession());
|
|
await tester.pumpAndSettle();
|
|
expect(container.read(authStatusProvider).value, isA<SignedIn>());
|
|
});
|
|
});
|
|
|
|
group('Sicherheit Kriterium 2: router redirects', () {
|
|
testWidgets('private route while signed out -> /login?from=', (
|
|
tester,
|
|
) async {
|
|
final (container: _, :router) = await start(tester);
|
|
|
|
router.go('/private?tab=1');
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(currentPath(router), '/login');
|
|
expect(currentUri(router).queryParameters['from'], '/private?tab=1');
|
|
expect(find.byKey(const Key('login_screen')), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('signing in on /login returns to from', (tester) async {
|
|
final (container: _, :router) = await start(tester);
|
|
router.go('/private');
|
|
await tester.pumpAndSettle();
|
|
|
|
emit(AuthChangeEvent.signedIn, withSession: testSession());
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(currentPath(router), '/private');
|
|
});
|
|
|
|
testWidgets('signed in: login and register lead to the start page', (
|
|
tester,
|
|
) async {
|
|
session = testSession();
|
|
final (container: _, :router) = await start(tester);
|
|
|
|
router.go('/login');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/');
|
|
|
|
router.go('/register');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/');
|
|
});
|
|
|
|
testWidgets('routes of spec 5.3 exist', (tester) async {
|
|
final (container: _, :router) = await start(tester);
|
|
|
|
for (final (path, key) in [
|
|
('/login', 'login_screen'),
|
|
('/register', 'register_screen'),
|
|
('/register/check-email', 'check_email_screen'),
|
|
('/forgot-password', 'forgot_password_screen'),
|
|
('/forgot-password/check-email', 'check_email_screen'),
|
|
('/auth/confirm?token_hash=x&type=email', 'email_link_screen'),
|
|
]) {
|
|
router.go(path);
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(Key(key)), findsOneWidget, reason: path);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () {
|
|
testWidgets('recovery forces /reset-password until the password changed', (
|
|
tester,
|
|
) async {
|
|
final (container: _, :router) = await start(tester);
|
|
|
|
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/reset-password');
|
|
expect(find.byKey(const Key('reset_password_screen')), findsOneWidget);
|
|
|
|
router.go('/');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/reset-password');
|
|
|
|
emit(AuthChangeEvent.tokenRefreshed, withSession: testSession());
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/reset-password');
|
|
|
|
// The password change alone does not release the guard: the success
|
|
// message must stay visible (KTO-01 Kriterium 9).
|
|
emit(AuthChangeEvent.userUpdated, withSession: testSession());
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/reset-password');
|
|
expect(
|
|
find.byKey(const Key('reset_password_success_banner')),
|
|
findsOneWidget,
|
|
);
|
|
|
|
router.go('/');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/reset-password');
|
|
});
|
|
|
|
testWidgets('aborting the recovery (signOut) leaves /reset-password', (
|
|
tester,
|
|
) async {
|
|
final (container: _, :router) = await start(tester);
|
|
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
|
|
await tester.pumpAndSettle();
|
|
|
|
emit(AuthChangeEvent.signedOut);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(currentPath(router), '/login');
|
|
});
|
|
|
|
testWidgets('onSignedOut runs on signed-in -> signed-out only', (
|
|
tester,
|
|
) async {
|
|
await start(tester);
|
|
expect(signedOutCalls, 0, reason: 'initial signed-out state');
|
|
|
|
emit(AuthChangeEvent.signedIn, withSession: testSession());
|
|
await tester.pumpAndSettle();
|
|
expect(signedOutCalls, 0);
|
|
|
|
emit(AuthChangeEvent.signedOut);
|
|
await tester.pumpAndSettle();
|
|
expect(signedOutCalls, 1);
|
|
|
|
emit(AuthChangeEvent.signedOut);
|
|
await tester.pumpAndSettle();
|
|
expect(signedOutCalls, 1, reason: 'no transition');
|
|
});
|
|
|
|
testWidgets('a failed refresh on a private route ends on /login', (
|
|
tester,
|
|
) async {
|
|
session = testSession();
|
|
final (container: _, :router) = await start(tester);
|
|
router.go('/private');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/private');
|
|
|
|
emit(AuthChangeEvent.signedOut);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(currentPath(router), '/login');
|
|
expect(signedOutCalls, 1);
|
|
});
|
|
});
|
|
|
|
group('KTO-01 Kriterium 8, 9: password reset flow through the router', () {
|
|
void stubGoTrue() {
|
|
registerFallbackValue(OtpType.recovery);
|
|
registerFallbackValue(UserAttributes());
|
|
when(
|
|
() => mocks.auth.verifyOTP(
|
|
type: any(named: 'type'),
|
|
tokenHash: any(named: 'tokenHash'),
|
|
),
|
|
).thenAnswer((_) async {
|
|
// gotrue emits passwordRecovery for a redeemed recovery link.
|
|
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
|
|
return AuthResponse(session: testSession());
|
|
});
|
|
when(() => mocks.auth.updateUser(any())).thenAnswer((_) async {
|
|
// gotrue emits userUpdated before updateUser returns.
|
|
emit(AuthChangeEvent.userUpdated, withSession: testSession());
|
|
return UserResponse.fromJson(testUser().toJson());
|
|
});
|
|
when(() => mocks.auth.signOut()).thenAnswer((_) async {
|
|
emit(AuthChangeEvent.signedOut);
|
|
});
|
|
}
|
|
|
|
Future<void> redeemRecoveryLink(
|
|
WidgetTester tester,
|
|
GoRouter router,
|
|
) async {
|
|
router.go('/auth/confirm?token_hash=abc&type=recovery');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/auth/confirm');
|
|
await tester.tap(find.byKey(const Key('email_link_submit')));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Future<void> savePassword(WidgetTester tester, String password) async {
|
|
await tester.enterText(
|
|
find.descendant(
|
|
of: find.byKey(const Key('reset_password_password')),
|
|
matching: find.byType(TextField),
|
|
),
|
|
password,
|
|
);
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const Key('reset_password_submit')));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
testWidgets('link -> new password -> message stays -> continue -> start', (
|
|
tester,
|
|
) async {
|
|
stubGoTrue();
|
|
final (container: _, :router) = await start(tester);
|
|
|
|
await redeemRecoveryLink(tester, router);
|
|
expect(currentPath(router), '/reset-password');
|
|
expect(find.byKey(const Key('reset_password_screen')), findsOneWidget);
|
|
|
|
// Navigating elsewhere is blocked while the guard holds.
|
|
router.go('/');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/reset-password');
|
|
|
|
await savePassword(tester, 'ein-langes-passwort');
|
|
|
|
verify(() => mocks.auth.updateUser(any())).called(1);
|
|
// The status moved to "changed", but the page and its message stay.
|
|
expect(currentPath(router), '/reset-password');
|
|
expect(
|
|
find.byKey(const Key('reset_password_success_banner')),
|
|
findsOneWidget,
|
|
);
|
|
router.go('/');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/reset-password');
|
|
|
|
await tester.tap(find.byKey(const Key('reset_password_continue')));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(currentPath(router), '/');
|
|
// The session stays: the user is signed in with the new password.
|
|
router.go('/login');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/');
|
|
verifyNever(() => mocks.auth.signOut());
|
|
});
|
|
|
|
testWidgets('cancel signs out and leads to /login', (tester) async {
|
|
stubGoTrue();
|
|
final (container: _, :router) = await start(tester);
|
|
await redeemRecoveryLink(tester, router);
|
|
expect(currentPath(router), '/reset-password');
|
|
|
|
await tester.tap(find.byKey(const Key('reset_password_cancel')));
|
|
await tester.pumpAndSettle();
|
|
|
|
verify(() => mocks.auth.signOut()).called(1);
|
|
expect(currentPath(router), '/login');
|
|
});
|
|
|
|
testWidgets('a signed-in user cannot open the reset page', (tester) async {
|
|
session = testSession();
|
|
final (container: _, :router) = await start(tester);
|
|
|
|
router.go('/reset-password');
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(currentPath(router), '/');
|
|
});
|
|
});
|
|
|
|
group('KTO-01 Kriterium 1: registrationInputFactory', () {
|
|
testWidgets('fills terms_version from AppConfig and the app language', (
|
|
tester,
|
|
) async {
|
|
final (:container, router: _) = await start(tester);
|
|
|
|
final input = container.read(registrationInputFactoryProvider)(
|
|
email: 'a@b.de',
|
|
password: 'pw',
|
|
displayName: 'Anna',
|
|
ageConfirmed: true,
|
|
termsAccepted: true,
|
|
);
|
|
|
|
expect(
|
|
input,
|
|
const RegistrationInput(
|
|
email: 'a@b.de',
|
|
password: 'pw',
|
|
displayName: 'Anna',
|
|
ageConfirmed: true,
|
|
termsAccepted: true,
|
|
termsVersion: 'test-1',
|
|
locale: 'en',
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
typedef ProviderContainerAndRouter = ({
|
|
ProviderContainer container,
|
|
GoRouter router,
|
|
});
|