Shuffle/app/test/features/auth/presentation/login/login_controller_test.dart
cschlaefke 57305dad4b fix(auth): address review findings for login
Fixed-wording tests for the generic credentials error, redacted toString for
login/register state, shared AuthFailure.isRetryable, autofill context
finished even when the router removes the screen, stricter from validation
(encoded and control-character variants), more widget tests.

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:07:56 +01:00

242 lines
7.5 KiB
Dart

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart';
import 'package:shuffle/features/auth/presentation/login/login_controller.dart';
import 'package:shuffle/features/auth/presentation/login/login_state.dart';
import '../auth_ui_support.dart';
void main() {
late MockAuthRepository repository;
late ProviderContainer container;
setUpAll(registerAuthFallbacks);
setUp(() {
repository = MockAuthRepository();
container = ProviderContainer.test(overrides: authUiOverrides(repository));
keepAlive(container, loginControllerProvider);
keepAlive(container, resendControllerProvider);
});
LoginController notifier() =>
container.read(loginControllerProvider.notifier);
LoginState state() => container.read(loginControllerProvider);
void stubSignIn(Result<void, AuthFailure> result) =>
when(() => repository.signIn(any(), any()))
.thenAnswer((_) async => result);
void fill([String email = ' Anna@Example.com ', String password = 'pw']) {
notifier()
..emailChanged(email)
..passwordChanged(password);
}
group('KTO-01 Kriterium 5: sign in', () {
test('sends the trimmed address, continues to the start page', () async {
stubSignIn(ok());
fill();
final target = await notifier().submit();
verify(() => repository.signIn('Anna@Example.com', 'pw')).called(1);
expect(target, '/');
// The form is left after success; it is not unlocked again.
expect(state().isSubmitting, isTrue);
expect(state().bannerFailure, isNull);
});
test('continues to a safe from path', () async {
stubSignIn(ok());
fill();
expect(await notifier().submit(from: '/games/7?tab=1'), '/games/7?tab=1');
});
for (final unsafe in [
'https://evil.example/x',
'//evil.example',
r'/\evil.example',
'javascript:alert(1)',
'games',
'/login',
'/register',
'/reset-password',
'/auth/confirm?token_hash=x',
'/%2F%2Fevil.example',
'%2F%2Fevil',
'/\t/evil.example',
'/%5Cevil.example',
'',
]) {
test('ignores the unsafe from "$unsafe"', () async {
stubSignIn(ok());
fill();
expect(await notifier().submit(from: unsafe), '/');
});
}
test('empty fields are not sent and ask for focus', () async {
await notifier().submit();
verifyNever(() => repository.signIn(any(), any()));
expect(state().shownEmailError, isTrue);
expect(state().shownPasswordRequired, isTrue);
expect(state().focusField, LoginField.email);
expect(state().focusRequest, 1);
expect(state().isSubmitting, isFalse);
});
test('an invalid address is not sent', () async {
fill('anna', 'pw');
expect(await notifier().submit(), isNull);
verifyNever(() => repository.signIn(any(), any()));
expect(state().shownEmailError, isTrue);
expect(state().shownPasswordRequired, isFalse);
});
test('blur shows the error of that field only', () {
notifier().fieldBlurred(LoginField.password);
expect(state().shownPasswordRequired, isTrue);
expect(state().shownEmailError, isFalse);
});
test('a second submit while one runs is ignored', () async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.signIn(any(), any()))
.thenAnswer((_) => completer.future);
fill();
final first = notifier().submit();
await Future<void>.delayed(Duration.zero);
expect(state().isSubmitting, isTrue);
expect(await notifier().submit(), isNull);
completer.complete(ok());
await first;
verify(() => repository.signIn(any(), any())).called(1);
});
});
group('no credentials in logs', () {
test('toString masks email and password', () {
fill('anna@example.com', 'geheim-und-lang');
final text = state().toString();
expect(text, isNot(contains('anna@example.com')));
expect(text, isNot(contains('geheim-und-lang')));
expect(text, contains('<redacted>'));
});
});
group('KTO-01 Kriterium 7: failures', () {
test('invalid credentials keep the input and show the failure', () async {
stubSignIn(failed(const AuthFailure.invalidCredentials()));
fill();
expect(await notifier().submit(), isNull);
expect(state().bannerFailure, const AuthFailure.invalidCredentials());
expect(state().email, ' Anna@Example.com ');
expect(state().password, 'pw');
expect(state().shownEmailError, isFalse);
expect(state().shownPasswordRequired, isFalse);
expect(state().showsNotConfirmed, isFalse);
});
for (final failure in const [
AuthFailure.rateLimited(),
AuthFailure.network(),
AuthFailure.unexpected(),
]) {
test('${failure.runtimeType} is shown as banner', () async {
stubSignIn(failed(failure));
fill();
await notifier().submit();
expect(state().bannerFailure, failure);
expect(state().isSubmitting, isFalse);
});
}
test('a new attempt clears the old failure', () async {
stubSignIn(failed(const AuthFailure.invalidCredentials()));
fill();
await notifier().submit();
stubSignIn(ok());
await notifier().submit();
expect(state().bannerFailure, isNull);
});
});
group('KTO-01 Kriterium 6: unconfirmed account', () {
setUp(() {
stubSignIn(failed(const AuthFailure.emailNotConfirmed()));
fill();
});
test('marks the address for resend', () async {
await notifier().submit();
expect(state().showsNotConfirmed, isTrue);
expect(state().unconfirmedEmail, 'Anna@Example.com');
});
test('resend uses the address of the attempt, not the field now', () async {
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => ok());
await notifier().submit();
notifier().emailChanged('other@example.com');
await notifier().resendConfirmation();
verify(() => repository.resendConfirmation('Anna@Example.com')).called(1);
expect(container.read(resendControllerProvider).sent, isTrue);
});
test('emailSendLimited shows the throttle hint (login origin)', () async {
when(
() => repository.resendConfirmation(any()),
).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited()));
await notifier().submit();
await notifier().resendConfirmation();
final resend = container.read(resendControllerProvider);
expect(resend.failure, const AuthFailure.rateLimited());
expect(resend.sent, isFalse);
});
test('resend without a pending attempt does nothing', () async {
await notifier().resendConfirmation();
verifyNever(() => repository.resendConfirmation(any()));
});
test('a new attempt resets the resend result', () async {
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => ok());
await notifier().submit();
await notifier().resendConfirmation();
expect(container.read(resendControllerProvider).sent, isTrue);
await notifier().submit();
expect(container.read(resendControllerProvider).sent, isFalse);
});
});
}