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>
This commit is contained in:
parent
dd91c078e0
commit
57305dad4b
14 changed files with 218 additions and 46 deletions
|
|
@ -36,3 +36,8 @@ String weakPasswordMessage(
|
||||||
};
|
};
|
||||||
return texts.isEmpty ? l10n.authErrorWeakPasswordCharacters : texts.join(' ');
|
return texts.isEmpty ? l10n.authErrorWeakPasswordCharacters : texts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Failures the user can fix by simply trying again (offline, unexpected).
|
||||||
|
extension AuthFailureRetry on AuthFailure {
|
||||||
|
bool get isRetryable => this is NetworkFailure || this is Unexpected;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,17 @@ class LoginController extends _$LoginController {
|
||||||
final result = await ref
|
final result = await ref
|
||||||
.read(authRepositoryProvider)
|
.read(authRepositoryProvider)
|
||||||
.signIn(email, state.password);
|
.signIn(email, state.password);
|
||||||
if (!ref.mounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final failure = result.errorOrNull;
|
final failure = result.errorOrNull;
|
||||||
if (failure == null) {
|
if (failure == null) {
|
||||||
state = state.copyWith(status: LoginStatus.editing);
|
// Success: the target is returned even if the screen is already gone
|
||||||
|
// (the router redirect can remove it before this future completes), so
|
||||||
|
// the caller can still finish the autofill context. The status stays
|
||||||
|
// `submitting`, the form is not reused after a successful sign-in.
|
||||||
return safeFromPath(from) ?? AppRoutes.home;
|
return safeFromPath(from) ?? AppRoutes.home;
|
||||||
}
|
}
|
||||||
|
if (!ref.mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: LoginStatus.editing,
|
status: LoginStatus.editing,
|
||||||
bannerFailure: failure,
|
bannerFailure: failure,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ final class LoginControllerProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$loginControllerHash() => r'f8c37f2b7b942c8cfffbf8b61f3296872b5bcb70';
|
String _$loginControllerHash() => r'693066c260db8abe8f70d04b88b216b2e0411579';
|
||||||
|
|
||||||
/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7).
|
/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
final target = await ref
|
final target = await ref
|
||||||
.read(loginControllerProvider.notifier)
|
.read(loginControllerProvider.notifier)
|
||||||
.submit(from: widget.from);
|
.submit(from: widget.from);
|
||||||
if (!mounted || target == null) {
|
if (target == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Also when the router redirect (status signedIn) already removed this
|
||||||
|
// screen: the password manager must still learn that sign-in worked.
|
||||||
TextInput.finishAutofillContext();
|
TextInput.finishAutofillContext();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
context.go(target);
|
context.go(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,13 +141,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
message: l10n.authCheckEmailResent,
|
message: l10n.authCheckEmailResent,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (state.showsNotConfirmed && resend.failure != null) ...[
|
if ((state.showsNotConfirmed, resend.failure) case (
|
||||||
|
true,
|
||||||
|
final resendFailure?,
|
||||||
|
)) ...[
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
AppBanner(
|
AppBanner(
|
||||||
key: const Key('login_resend_error_banner'),
|
key: const Key('login_resend_error_banner'),
|
||||||
type: AppBannerType.error,
|
type: AppBannerType.error,
|
||||||
typeLabel: l10n.authBannerTypeError,
|
typeLabel: l10n.authBannerTypeError,
|
||||||
message: authFailureMessage(l10n, resend.failure!),
|
message: authFailureMessage(l10n, resendFailure),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
@ -226,7 +234,7 @@ class _FailureBanner extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final retryable = failure is NetworkFailure || failure is Unexpected;
|
final retryable = failure.isRetryable;
|
||||||
return AppBanner(
|
return AppBanner(
|
||||||
type: AppBannerType.error,
|
type: AppBannerType.error,
|
||||||
typeLabel: l10n.authBannerTypeError,
|
typeLabel: l10n.authBannerTypeError,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ abstract class LoginState with _$LoginState {
|
||||||
|
|
||||||
const LoginState._();
|
const LoginState._();
|
||||||
|
|
||||||
|
/// No credentials in logs or error reports.
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'LoginState(email: <redacted>, password: <redacted>, status: $status, '
|
||||||
|
'bannerFailure: $bannerFailure)';
|
||||||
|
|
||||||
bool get isSubmitting => status == LoginStatus.submitting;
|
bool get isSubmitting => status == LoginStatus.submitting;
|
||||||
|
|
||||||
EmailError? get emailError => EmailFormat.validate(email);
|
EmailError? get emailError => EmailFormat.validate(email);
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,6 @@ int get hashCode {
|
||||||
return Object.hash(runtimeType,_this.email,_this.password,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.bannerFailure,_this.unconfirmedEmail,_this.focusField,_this.focusRequest);
|
return Object.hash(runtimeType,_this.email,_this.password,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.bannerFailure,_this.unconfirmedEmail,_this.focusField,_this.focusRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
final _this = this as LoginState;
|
|
||||||
return 'LoginState(email: ${_this.email}, password: ${_this.password}, visibleFields: ${_this.visibleFields}, status: ${_this.status}, bannerFailure: ${_this.bannerFailure}, unconfirmedEmail: ${_this.unconfirmedEmail}, focusField: ${_this.focusField}, focusRequest: ${_this.focusRequest})';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -278,10 +273,6 @@ int get hashCode {
|
||||||
return Object.hash(runtimeType,email,password,const DeepCollectionEquality().hash(_visibleFields),status,bannerFailure,unconfirmedEmail,focusField,focusRequest);
|
return Object.hash(runtimeType,email,password,const DeepCollectionEquality().hash(_visibleFields),status,bannerFailure,unconfirmedEmail,focusField,focusRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'LoginState(email: $email, password: $password, visibleFields: $visibleFields, status: $status, bannerFailure: $bannerFailure, unconfirmedEmail: $unconfirmedEmail, focusField: $focusField, focusRequest: $focusRequest)';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,21 @@ String? safeFromPath(String? from) {
|
||||||
if (from.startsWith('//') || from.startsWith(r'/\')) {
|
if (from.startsWith('//') || from.startsWith(r'/\')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Browsers drop tabs and line breaks inside URLs (`/\t/evil` -> `//evil`),
|
||||||
|
// so control characters are never allowed.
|
||||||
|
if (RegExp(r'[\x00-\x1f\x7f]').hasMatch(from)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final uri = Uri.tryParse(from);
|
final uri = Uri.tryParse(from);
|
||||||
if (uri == null || uri.hasScheme || uri.hasAuthority) {
|
if (uri == null || uri.hasScheme || uri.hasAuthority) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Percent-encoded variants (`/%2F%2Fevil`, `/%5Cevil`) must not decode to a
|
||||||
|
// protocol-relative path either.
|
||||||
|
final decoded = Uri.decodeFull(uri.path);
|
||||||
|
if (decoded.startsWith('//') || decoded.startsWith(r'/\')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
// `/login/` is the same page as `/login` (go_router ignores the trailing
|
// `/login/` is the same page as `/login` (go_router ignores the trailing
|
||||||
// slash), so it is not allowed either.
|
// slash), so it is not allowed either.
|
||||||
final path = uri.path.length > 1
|
final path = uri.path.length > 1
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ class _FailureBanner extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final retryable = failure is NetworkFailure || failure is Unexpected;
|
final retryable = failure.isRetryable;
|
||||||
return AppBanner(
|
return AppBanner(
|
||||||
type: AppBannerType.error,
|
type: AppBannerType.error,
|
||||||
typeLabel: l10n.authBannerTypeError,
|
typeLabel: l10n.authBannerTypeError,
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,13 @@ abstract class RegisterState with _$RegisterState {
|
||||||
|
|
||||||
const RegisterState._();
|
const RegisterState._();
|
||||||
|
|
||||||
|
/// No credentials or personal data in logs or error reports.
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'RegisterState(email: <redacted>, password: <redacted>, '
|
||||||
|
'displayName: <redacted>, status: $status, '
|
||||||
|
'bannerFailure: $bannerFailure)';
|
||||||
|
|
||||||
bool get isSubmitting => status == RegisterStatus.submitting;
|
bool get isSubmitting => status == RegisterStatus.submitting;
|
||||||
|
|
||||||
EmailError? get emailError => EmailFormat.validate(email);
|
EmailError? get emailError => EmailFormat.validate(email);
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,6 @@ int get hashCode {
|
||||||
return Object.hash(runtimeType,_this.email,_this.password,_this.displayName,_this.ageConfirmed,_this.termsAccepted,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.commonPasswords,_this.bannerFailure,const DeepCollectionEquality().hash(_this.serverPasswordReasons),_this.serverEmailInvalid,_this.focusField,_this.focusRequest);
|
return Object.hash(runtimeType,_this.email,_this.password,_this.displayName,_this.ageConfirmed,_this.termsAccepted,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.commonPasswords,_this.bannerFailure,const DeepCollectionEquality().hash(_this.serverPasswordReasons),_this.serverEmailInvalid,_this.focusField,_this.focusRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
final _this = this as RegisterState;
|
|
||||||
return 'RegisterState(email: ${_this.email}, password: ${_this.password}, displayName: ${_this.displayName}, ageConfirmed: ${_this.ageConfirmed}, termsAccepted: ${_this.termsAccepted}, visibleFields: ${_this.visibleFields}, status: ${_this.status}, commonPasswords: ${_this.commonPasswords}, bannerFailure: ${_this.bannerFailure}, serverPasswordReasons: ${_this.serverPasswordReasons}, serverEmailInvalid: ${_this.serverEmailInvalid}, focusField: ${_this.focusField}, focusRequest: ${_this.focusRequest})';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -301,10 +296,6 @@ int get hashCode {
|
||||||
return Object.hash(runtimeType,email,password,displayName,ageConfirmed,termsAccepted,const DeepCollectionEquality().hash(_visibleFields),status,commonPasswords,bannerFailure,const DeepCollectionEquality().hash(_serverPasswordReasons),serverEmailInvalid,focusField,focusRequest);
|
return Object.hash(runtimeType,email,password,displayName,ageConfirmed,termsAccepted,const DeepCollectionEquality().hash(_visibleFields),status,commonPasswords,bannerFailure,const DeepCollectionEquality().hash(_serverPasswordReasons),serverEmailInvalid,focusField,focusRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'RegisterState(email: $email, password: $password, displayName: $displayName, ageConfirmed: $ageConfirmed, termsAccepted: $termsAccepted, visibleFields: $visibleFields, status: $status, commonPasswords: $commonPasswords, bannerFailure: $bannerFailure, serverPasswordReasons: $serverPasswordReasons, serverEmailInvalid: $serverEmailInvalid, focusField: $focusField, focusRequest: $focusRequest)';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,5 +150,13 @@ void main() {
|
||||||
expect(safeFromPath(''), isNull);
|
expect(safeFromPath(''), isNull);
|
||||||
expect(safeFromPath('javascript:alert(1)'), isNull);
|
expect(safeFromPath('javascript:alert(1)'), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects encoded and control-character tricks', () {
|
||||||
|
expect(safeFromPath('/%2F%2Fevil.example'), isNull);
|
||||||
|
expect(safeFromPath('%2F%2Fevil'), isNull);
|
||||||
|
expect(safeFromPath('/%5Cevil.example'), isNull);
|
||||||
|
expect(safeFromPath('/\t/evil.example'), isNull);
|
||||||
|
expect(safeFromPath('/a\nb'), isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,8 @@ void main() {
|
||||||
|
|
||||||
verify(() => repository.signIn('Anna@Example.com', 'pw')).called(1);
|
verify(() => repository.signIn('Anna@Example.com', 'pw')).called(1);
|
||||||
expect(target, '/');
|
expect(target, '/');
|
||||||
expect(state().isSubmitting, isFalse);
|
// The form is left after success; it is not unlocked again.
|
||||||
|
expect(state().isSubmitting, isTrue);
|
||||||
expect(state().bannerFailure, isNull);
|
expect(state().bannerFailure, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -68,6 +69,10 @@ void main() {
|
||||||
'/register',
|
'/register',
|
||||||
'/reset-password',
|
'/reset-password',
|
||||||
'/auth/confirm?token_hash=x',
|
'/auth/confirm?token_hash=x',
|
||||||
|
'/%2F%2Fevil.example',
|
||||||
|
'%2F%2Fevil',
|
||||||
|
'/\t/evil.example',
|
||||||
|
'/%5Cevil.example',
|
||||||
'',
|
'',
|
||||||
]) {
|
]) {
|
||||||
test('ignores the unsafe from "$unsafe"', () async {
|
test('ignores the unsafe from "$unsafe"', () async {
|
||||||
|
|
@ -123,6 +128,18 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () {
|
group('KTO-01 Kriterium 7: failures', () {
|
||||||
test('invalid credentials keep the input and show the failure', () async {
|
test('invalid credentials keep the input and show the failure', () async {
|
||||||
stubSignIn(failed(const AuthFailure.invalidCredentials()));
|
stubSignIn(failed(const AuthFailure.invalidCredentials()));
|
||||||
|
|
|
||||||
|
|
@ -243,9 +243,13 @@ void main() {
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'wrong email and wrong password show exactly the same screen text',
|
'wrong email and wrong password show exactly the same screen text',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
// The repository maps both cases to invalidCredentials (Spec 4.3); the
|
// Screen level only: the repository mock returns invalidCredentials
|
||||||
// screen must not add anything that tells them apart.
|
// for both cases, so this proves the screen adds nothing that tells
|
||||||
|
// them apart. The real proof that an unknown address and a wrong
|
||||||
|
// password yield the same `invalid_credentials` answer is the API
|
||||||
|
// E2E test (T3).
|
||||||
stubSignInFailure(const AuthFailure.invalidCredentials());
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
||||||
|
final handle = tester.ensureSemantics();
|
||||||
await open(tester);
|
await open(tester);
|
||||||
await type(tester, 'login_email', 'unknown@example.com');
|
await type(tester, 'login_email', 'unknown@example.com');
|
||||||
await type(tester, 'login_password', 'irgendein-passwort');
|
await type(tester, 'login_password', 'irgendein-passwort');
|
||||||
|
|
@ -258,22 +262,16 @@ void main() {
|
||||||
final wrongPassword = visibleTexts(tester);
|
final wrongPassword = visibleTexts(tester);
|
||||||
|
|
||||||
expect(wrongEmail, wrongPassword);
|
expect(wrongEmail, wrongPassword);
|
||||||
expect(wrongEmail, contains(_de.authErrorInvalidCredentials));
|
// Fixed wording, independent of the l10n getter.
|
||||||
verify(() => repository.signIn(any(), any())).called(2);
|
const fixed = 'E-Mail-Adresse oder Passwort ist nicht korrekt.';
|
||||||
|
expect(wrongEmail, contains(fixed));
|
||||||
|
expect(_de.authErrorInvalidCredentials, fixed);
|
||||||
|
expect(find.text(fixed), findsOneWidget);
|
||||||
|
expect(find.bySemanticsLabel('Fehler: $fixed'), findsOneWidget);
|
||||||
|
handle.dispose();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('english text of the generic failure', (tester) async {
|
|
||||||
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
||||||
await open(tester, locale: const Locale('en'));
|
|
||||||
await fill(tester);
|
|
||||||
|
|
||||||
await submit(tester);
|
|
||||||
|
|
||||||
expect(find.text(_en.authErrorInvalidCredentials), findsOneWidget);
|
|
||||||
expect(find.text(_de.authErrorInvalidCredentials), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('throttling (429) shows the throttle hint', (tester) async {
|
testWidgets('throttling (429) shows the throttle hint', (tester) async {
|
||||||
stubSignInFailure(const AuthFailure.rateLimited());
|
stubSignInFailure(const AuthFailure.rateLimited());
|
||||||
await open(tester);
|
await open(tester);
|
||||||
|
|
@ -378,6 +376,99 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('KTO-01 Kriterium 5: autofill context', () {
|
||||||
|
// Only calls that save the credentials (`shouldSave: true`).
|
||||||
|
int finishCalls(WidgetTester tester) => tester.testTextInput.log
|
||||||
|
.where(
|
||||||
|
(call) =>
|
||||||
|
call.method == 'TextInput.finishAutofillContext' &&
|
||||||
|
call.arguments == true,
|
||||||
|
)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
testWidgets('is finished after a successful sign-in', (tester) async {
|
||||||
|
await open(tester);
|
||||||
|
await fill(tester);
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
await submit(tester);
|
||||||
|
|
||||||
|
// Our explicit call, plus the AutofillGroup committing on disposal
|
||||||
|
// when the screen is replaced.
|
||||||
|
expect(finishCalls(tester), greaterThanOrEqualTo(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('is finished even if the router already removed the screen', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final completer = Completer<Result<void, AuthFailure>>();
|
||||||
|
when(() => repository.signIn(any(), any()))
|
||||||
|
.thenAnswer((_) => completer.future);
|
||||||
|
final (:container, :router) = await open(tester);
|
||||||
|
await fill(tester);
|
||||||
|
await tapKey(tester, 'login_submit');
|
||||||
|
// The status change makes the router redirect away before the
|
||||||
|
// sign-in future completes.
|
||||||
|
router.go('/private');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('login_screen')), findsNothing);
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
completer.complete(ok());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(finishCalls(tester), 1);
|
||||||
|
expect(currentPath(router), '/private');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('is not finished after a failed sign-in', (tester) async {
|
||||||
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
||||||
|
await open(tester);
|
||||||
|
await fill(tester);
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
|
||||||
|
await submit(tester);
|
||||||
|
|
||||||
|
expect(finishCalls(tester), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Zustände Kriterium 3: banners and focus', () {
|
||||||
|
testWidgets('unexpected error shows the banner with retry', (tester) async {
|
||||||
|
stubSignInFailure(const AuthFailure.unexpected());
|
||||||
|
await open(tester);
|
||||||
|
await fill(tester);
|
||||||
|
|
||||||
|
await submit(tester);
|
||||||
|
|
||||||
|
expect(find.text(_de.authErrorUnexpected), findsOneWidget);
|
||||||
|
expect(find.text(_de.authActionRetry), findsOneWidget);
|
||||||
|
stubSignInFailure(const AuthFailure.unexpected());
|
||||||
|
await tester.tap(find.text(_de.authActionRetry));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
verify(() => repository.signIn(any(), any())).called(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a failed submit focuses the first invalid field', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester);
|
||||||
|
|
||||||
|
await submit(tester);
|
||||||
|
expect(
|
||||||
|
tester.widget<TextField>(fieldOf('login_email')).focusNode!.hasFocus,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
await type(tester, 'login_email', 'anna@example.com');
|
||||||
|
await submit(tester);
|
||||||
|
expect(
|
||||||
|
tester.widget<TextField>(fieldOf('login_password')).focusNode!.hasFocus,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('QUE-05 Kriterium 2: de and en', () {
|
group('QUE-05 Kriterium 2: de and en', () {
|
||||||
testWidgets('english texts', (tester) async {
|
testWidgets('english texts', (tester) async {
|
||||||
await open(tester, locale: const Locale('en'));
|
await open(tester, locale: const Locale('en'));
|
||||||
|
|
@ -405,6 +496,23 @@ void main() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
|
||||||
|
testWidgets('error banner meets the contrast guideline ($mode)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final handle = tester.ensureSemantics();
|
||||||
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
||||||
|
await open(tester, themeMode: mode);
|
||||||
|
await fill(tester);
|
||||||
|
await submit(tester);
|
||||||
|
expect(find.byKey(const Key('login_banner')), findsOneWidget);
|
||||||
|
|
||||||
|
await expectLater(tester, meetsGuideline(textContrastGuideline));
|
||||||
|
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
|
||||||
|
handle.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
testWidgets('the title is a heading and the banner a live region', (
|
testWidgets('the title is a heading and the banner a live region', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -416,7 +524,7 @@ void main() {
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.bySemanticsLabel(
|
find.bySemanticsLabel(
|
||||||
'${_de.authBannerTypeError}: ${_de.authErrorInvalidCredentials}',
|
'Fehler: E-Mail-Adresse oder Passwort ist nicht korrekt.',
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,24 @@ void main() {
|
||||||
..termsAcceptedChanged(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', () {
|
group('KTO-01 Kriterium 1: submit', () {
|
||||||
test('KTO-01 Kriterium 11 (Client): sends all data, terms version and locale to signUp', () async {
|
test('KTO-01 Kriterium 11 (Client): sends all data, terms version and locale to signUp', () async {
|
||||||
final container = makeContainer(locale: const Locale('en'));
|
final container = makeContainer(locale: const Locale('en'));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue