462 lines
16 KiB
Dart
462 lines
16 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/widgets.dart';
|
||
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/domain/registration_input.dart';
|
||
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
|
||
import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart';
|
||
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
|
||
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
|
||
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
|
||
import 'package:shuffle/features/auth/presentation/register/register_controller.dart';
|
||
import 'package:shuffle/features/auth/presentation/register/register_state.dart';
|
||
|
||
import '../../../../helpers/fakes.dart';
|
||
import '../auth_ui_support.dart';
|
||
|
||
void main() {
|
||
late MockAuthRepository repository;
|
||
late List<Uri> opened;
|
||
late bool openResult;
|
||
|
||
setUpAll(registerAuthFallbacks);
|
||
|
||
setUp(() {
|
||
repository = MockAuthRepository();
|
||
opened = [];
|
||
openResult = true;
|
||
when(() => repository.signUp(any())).thenAnswer((_) async => ok());
|
||
});
|
||
|
||
ProviderContainer makeContainer({
|
||
Locale locale = const Locale('de'),
|
||
Future<CommonPasswordList> Function()? commonPasswords,
|
||
}) {
|
||
final container = ProviderContainer.test(
|
||
overrides: authUiOverrides(
|
||
repository,
|
||
locale: locale,
|
||
urlOpener: (uri) async {
|
||
opened.add(uri);
|
||
return openResult;
|
||
},
|
||
commonPasswords: commonPasswords,
|
||
),
|
||
);
|
||
keepAlive(container, registerControllerProvider);
|
||
return container;
|
||
}
|
||
|
||
RegisterController notifier(ProviderContainer c) =>
|
||
c.read(registerControllerProvider.notifier);
|
||
|
||
RegisterState stateOf(ProviderContainer c) =>
|
||
c.read(registerControllerProvider);
|
||
|
||
void fillValid(
|
||
RegisterController controller, {
|
||
String email = ' anna@example.com ',
|
||
String password = 'geheim-und-lang',
|
||
String name = ' Anna ',
|
||
}) {
|
||
controller
|
||
..emailChanged(email)
|
||
..passwordChanged(password)
|
||
..displayNameChanged(name)
|
||
..ageConfirmedChanged(value: true)
|
||
..termsAcceptedChanged(value: true);
|
||
}
|
||
|
||
group('no personal data in logs', () {
|
||
test('toString masks email, password and display name', () {
|
||
final container = makeContainer();
|
||
fillValid(
|
||
container.read(registerControllerProvider.notifier),
|
||
email: 'anna@example.com',
|
||
name: 'Anna Geheim',
|
||
);
|
||
|
||
final text = container.read(registerControllerProvider).toString();
|
||
|
||
expect(text, isNot(contains('anna@example.com')));
|
||
expect(text, isNot(contains('geheim-und-lang')));
|
||
expect(text, isNot(contains('Anna Geheim')));
|
||
expect(text, contains('<redacted>'));
|
||
});
|
||
});
|
||
|
||
group('KTO-01 Kriterium 1: submit', () {
|
||
test('KTO-01 Kriterium 11 (Client): sends all data, terms version and locale to signUp', () async {
|
||
final container = makeContainer(locale: const Locale('en'));
|
||
fillValid(notifier(container));
|
||
|
||
final result = await notifier(container).submit();
|
||
|
||
expect(result, isTrue);
|
||
final input =
|
||
verify(() => repository.signUp(captureAny())).captured.single
|
||
as RegistrationInput;
|
||
expect(input.email, 'anna@example.com');
|
||
expect(input.password, 'geheim-und-lang');
|
||
expect(input.displayName, 'Anna');
|
||
expect(input.ageConfirmed, isTrue);
|
||
expect(input.termsAccepted, isTrue);
|
||
expect(input.termsVersion, testAppConfig().termsVersion);
|
||
expect(input.locale, 'en');
|
||
expect(stateOf(container).isSubmitting, isFalse);
|
||
});
|
||
|
||
test('KTO-01 Kriterium 11 (Client): mail language follows the active UI language', () async {
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
|
||
await notifier(container).submit();
|
||
|
||
final input =
|
||
verify(() => repository.signUp(captureAny())).captured.single
|
||
as RegistrationInput;
|
||
expect(input.locale, 'de');
|
||
});
|
||
|
||
test('ignores a second submit while one is running', () async {
|
||
final completer = Completer<Result<void, AuthFailure>>();
|
||
when(() => repository.signUp(any())).thenAnswer((_) => completer.future);
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
|
||
final first = notifier(container).submit();
|
||
await Future<void>.delayed(Duration.zero);
|
||
expect(stateOf(container).isSubmitting, isTrue);
|
||
expect(await notifier(container).submit(), isFalse);
|
||
completer.complete(ok());
|
||
expect(await first, isTrue);
|
||
verify(() => repository.signUp(any())).called(1);
|
||
});
|
||
});
|
||
|
||
test(
|
||
'two triggers while the password list is still loading send only once',
|
||
() async {
|
||
final listLoaded = Completer<CommonPasswordList>();
|
||
final container = makeContainer(commonPasswords: () => listLoaded.future);
|
||
fillValid(notifier(container));
|
||
|
||
final first = notifier(container).submit();
|
||
final second = notifier(container).submit();
|
||
expect(stateOf(container).isSubmitting, isTrue);
|
||
expect(await second, isFalse);
|
||
|
||
listLoaded.complete(commonTestPasswords);
|
||
expect(await first, isTrue);
|
||
verify(() => repository.signUp(any())).called(1);
|
||
expect(stateOf(container).isSubmitting, isFalse);
|
||
},
|
||
);
|
||
|
||
test('an invalid form leaves the submitting state again', () async {
|
||
final container = makeContainer();
|
||
expect(await notifier(container).submit(), isFalse);
|
||
expect(stateOf(container).isSubmitting, isFalse);
|
||
});
|
||
|
||
group('KTO-01 Kriterium 2: minimum age', () {
|
||
test(
|
||
'without the age confirmation nothing is sent and the error shows',
|
||
() async {
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
notifier(container).ageConfirmedChanged(value: false);
|
||
|
||
expect(await notifier(container).submit(), isFalse);
|
||
|
||
verifyNever(() => repository.signUp(any()));
|
||
expect(stateOf(container).shownAgeError, isTrue);
|
||
expect(stateOf(container).focusField, RegisterField.ageConfirmation);
|
||
},
|
||
);
|
||
|
||
test('QUE-04 Kriterium 1: without consent nothing is sent', () async {
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
notifier(container).termsAcceptedChanged(value: false);
|
||
|
||
expect(await notifier(container).submit(), isFalse);
|
||
|
||
verifyNever(() => repository.signUp(any()));
|
||
expect(stateOf(container).shownTermsError, isTrue);
|
||
});
|
||
|
||
test('the error disappears when the box is ticked', () async {
|
||
final container = makeContainer();
|
||
await notifier(container).submit();
|
||
expect(stateOf(container).shownAgeError, isTrue);
|
||
|
||
notifier(container).ageConfirmedChanged(value: true);
|
||
|
||
expect(stateOf(container).shownAgeError, isFalse);
|
||
});
|
||
});
|
||
|
||
group('KTO-01 Kriterium 3: password', () {
|
||
test('is checked while typing: too short names the missing count', () {
|
||
final container = makeContainer();
|
||
|
||
notifier(container).passwordChanged('123456789');
|
||
|
||
expect(
|
||
stateOf(container).shownPasswordError,
|
||
const PasswordError.tooShort(1),
|
||
);
|
||
});
|
||
|
||
test('10 characters are enough', () {
|
||
final container = makeContainer();
|
||
notifier(container).passwordChanged('1234567890');
|
||
expect(stateOf(container).shownPasswordError, isNull);
|
||
});
|
||
|
||
test('a common password is rejected once the list is loaded', () async {
|
||
final container = makeContainer();
|
||
|
||
notifier(container).passwordChanged(' PassWort1234 ');
|
||
await notifier(container).loadCommonPasswords();
|
||
|
||
expect(stateOf(container).shownPasswordError, isA<PasswordCommon>());
|
||
expect(await notifier(container).submit(), isFalse);
|
||
verifyNever(() => repository.signUp(any()));
|
||
});
|
||
|
||
test('an empty password is shown as required after blur', () {
|
||
final container = makeContainer();
|
||
notifier(container).fieldBlurred(RegisterField.password);
|
||
expect(stateOf(container).shownPasswordRequired, isTrue);
|
||
});
|
||
|
||
test(
|
||
'weak_password from the server is mapped to the password field',
|
||
() async {
|
||
when(() => repository.signUp(any())).thenAnswer(
|
||
(_) async => failed(
|
||
const AuthFailure.weakPassword([WeakPasswordReason.length]),
|
||
),
|
||
);
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
|
||
expect(await notifier(container).submit(), isFalse);
|
||
|
||
expect(stateOf(container).serverPasswordReasons, [
|
||
WeakPasswordReason.length,
|
||
]);
|
||
expect(stateOf(container).bannerFailure, isNull);
|
||
expect(stateOf(container).focusField, RegisterField.password);
|
||
|
||
notifier(container).passwordChanged('anderes-passwort');
|
||
expect(stateOf(container).serverPasswordReasons, isNull);
|
||
},
|
||
);
|
||
|
||
test('Zustände Kriterium 3: if the list cannot be loaded, only the length rule runs', () async {
|
||
final container = makeContainer(
|
||
commonPasswords: () => Future.error(StateError('asset missing')),
|
||
);
|
||
fillValid(notifier(container), password: 'passwort1234');
|
||
|
||
expect(await notifier(container).submit(), isTrue);
|
||
});
|
||
});
|
||
|
||
group('KTO-01 Kriterium 4: no hint whether the account exists', () {
|
||
test('emailSendLimited counts as success (F12)', () async {
|
||
when(
|
||
() => repository.signUp(any()),
|
||
).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited()));
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
|
||
expect(await notifier(container).submit(), isTrue);
|
||
expect(stateOf(container).bannerFailure, isNull);
|
||
});
|
||
|
||
test('plain success leads to the same result', () async {
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
|
||
expect(await notifier(container).submit(), isTrue);
|
||
expect(stateOf(container).bannerFailure, isNull);
|
||
});
|
||
|
||
test('over_request_rate_limit stays a visible rate-limit banner', () async {
|
||
when(() => repository.signUp(any()))
|
||
.thenAnswer((_) async => failed(const AuthFailure.rateLimited()));
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
|
||
expect(await notifier(container).submit(), isFalse);
|
||
expect(stateOf(container).bannerFailure, const AuthFailure.rateLimited());
|
||
});
|
||
});
|
||
|
||
group('KTO-01 Kriterium 10: display name', () {
|
||
Future<DisplayNameError?> errorFor(String name) async {
|
||
final container = makeContainer();
|
||
notifier(container)
|
||
..displayNameChanged(name)
|
||
..fieldBlurred(RegisterField.displayName);
|
||
return stateOf(container).shownDisplayNameError;
|
||
}
|
||
|
||
test('empty and whitespace only are rejected', () async {
|
||
expect(await errorFor(''), DisplayNameError.empty);
|
||
expect(await errorFor(' '), DisplayNameError.empty);
|
||
});
|
||
|
||
test('30 code points are valid, 31 are too long', () async {
|
||
expect(await errorFor('a' * 30), isNull);
|
||
expect(await errorFor('a' * 31), DisplayNameError.tooLong);
|
||
});
|
||
|
||
test('counts code points, not UTF-16 units', () async {
|
||
expect(await errorFor('😀' * 30), isNull);
|
||
expect(await errorFor('😀' * 31), DisplayNameError.tooLong);
|
||
});
|
||
|
||
test('the counter uses the trimmed code point length', () {
|
||
final container = makeContainer();
|
||
notifier(container).displayNameChanged(' 😀😀 ');
|
||
expect(stateOf(container).displayNameLength, 2);
|
||
});
|
||
|
||
test('U+200B anywhere is invalid', () async {
|
||
expect(await errorFor('AB'), DisplayNameError.invalidCharacter);
|
||
expect(await errorFor(''), DisplayNameError.invalidCharacter);
|
||
});
|
||
|
||
test('an invalid name blocks the submit', () async {
|
||
final container = makeContainer();
|
||
fillValid(notifier(container), name: ' ');
|
||
|
||
expect(await notifier(container).submit(), isFalse);
|
||
|
||
verifyNever(() => repository.signUp(any()));
|
||
expect(stateOf(container).focusField, RegisterField.displayName);
|
||
});
|
||
});
|
||
|
||
group('KTO-01 Kriterium 1: email', () {
|
||
test(
|
||
'an invalid address is shown after blur and blocks the submit',
|
||
() async {
|
||
final container = makeContainer();
|
||
notifier(container)
|
||
..emailChanged('anna')
|
||
..fieldBlurred(RegisterField.email);
|
||
|
||
expect(stateOf(container).shownEmailError, EmailError.invalid);
|
||
expect(await notifier(container).submit(), isFalse);
|
||
},
|
||
);
|
||
|
||
test('invalid email from the server goes to the email field', () async {
|
||
when(() => repository.signUp(any()))
|
||
.thenAnswer((_) async => failed(const AuthFailure.invalidEmail()));
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
|
||
await notifier(container).submit();
|
||
|
||
expect(stateOf(container).serverEmailInvalid, isTrue);
|
||
expect(stateOf(container).focusField, RegisterField.email);
|
||
});
|
||
});
|
||
|
||
group('F13: SMTP outage looks like success', () {
|
||
test('unexpected (HTTP 5xx) continues like a 200', () async {
|
||
when(() => repository.signUp(any()))
|
||
.thenAnswer((_) async => failed(const AuthFailure.unexpected()));
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
|
||
expect(await notifier(container).submit(), isTrue);
|
||
expect(stateOf(container).bannerFailure, isNull);
|
||
});
|
||
});
|
||
|
||
group('Zustände Kriterium 2, 3: failures keep the input', () {
|
||
for (final failure in [
|
||
const AuthFailure.network(),
|
||
const AuthFailure.registrationRejected(),
|
||
]) {
|
||
test('${failure.runtimeType} becomes a banner', () async {
|
||
when(() => repository.signUp(any()))
|
||
.thenAnswer((_) async => failed(failure));
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
|
||
expect(await notifier(container).submit(), isFalse);
|
||
|
||
final state = stateOf(container);
|
||
expect(state.bannerFailure, failure);
|
||
expect(state.isSubmitting, isFalse);
|
||
expect(state.password, 'geheim-und-lang');
|
||
expect(state.displayName, ' Anna ');
|
||
expect(state.ageConfirmed, isTrue);
|
||
});
|
||
}
|
||
|
||
test('a new attempt clears the banner', () async {
|
||
when(() => repository.signUp(any()))
|
||
.thenAnswer((_) async => failed(const AuthFailure.network()));
|
||
final container = makeContainer();
|
||
fillValid(notifier(container));
|
||
await notifier(container).submit();
|
||
|
||
when(() => repository.signUp(any())).thenAnswer((_) async => ok());
|
||
expect(await notifier(container).submit(), isTrue);
|
||
expect(stateOf(container).bannerFailure, isNull);
|
||
});
|
||
});
|
||
|
||
group('QUE-04 Kriterium 1: legal texts', () {
|
||
test('open the URLs from the app config', () async {
|
||
final container = makeContainer();
|
||
|
||
expect(
|
||
await notifier(container).openLegalDocument(LegalDocument.privacy),
|
||
isTrue,
|
||
);
|
||
await notifier(container).openLegalDocument(LegalDocument.terms);
|
||
|
||
expect(opened, [
|
||
Uri.parse(testAppConfig().legalPrivacyUrl),
|
||
Uri.parse(testAppConfig().legalTermsUrl),
|
||
]);
|
||
});
|
||
|
||
test('report a failure to open', () async {
|
||
openResult = false;
|
||
final container = makeContainer();
|
||
expect(
|
||
await notifier(container).openLegalDocument(LegalDocument.terms),
|
||
isFalse,
|
||
);
|
||
});
|
||
});
|
||
|
||
test('factory provider reads terms version and locale', () {
|
||
final container = makeContainer();
|
||
final input = container.read(registrationInputFactoryProvider)(
|
||
email: 'a@b.de',
|
||
password: 'x',
|
||
displayName: 'n',
|
||
ageConfirmed: true,
|
||
termsAccepted: true,
|
||
);
|
||
expect(input.termsVersion, 'test-1');
|
||
expect(input.locale, 'de');
|
||
});
|
||
}
|