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>
242 lines
7.5 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|