Shuffle/app/test/features/auth/data/auth_failure_mapper_test.dart
cschlaefke 7d094a6732 fix(auth): treat SMTP failures like success on enumeration-sensitive calls
Refs #37

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

300 lines
8.5 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:shuffle/features/auth/data/auth_failure_mapper.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
AuthApiException _api(String code, {String status = '400'}) =>
AuthApiException('msg', statusCode: status, code: code);
const _triggerBody = '{"code":"P0001","message":"display_name_invalid"}';
void main() {
group('Spec #1 § 4.4 error mapping', () {
test('KTO-01 Kriterium 7: invalid_credentials', () {
expect(
mapAuthError(_api('invalid_credentials'), AuthOperation.signIn),
const AuthFailure.invalidCredentials(),
);
});
test('KTO-01 Kriterium 6: email_not_confirmed', () {
expect(
mapAuthError(_api('email_not_confirmed'), AuthOperation.signIn),
const AuthFailure.emailNotConfirmed(),
);
});
test('KTO-01 Kriterium 4: over_email_send_rate_limit -> emailSendLimited '
'for signUp, reset and resend', () {
for (final op in [
AuthOperation.signUp,
AuthOperation.requestPasswordReset,
AuthOperation.resend,
]) {
expect(
mapAuthError(_api('over_email_send_rate_limit', status: '429'), op),
const AuthFailure.emailSendLimited(),
reason: op.name,
);
}
});
test('over_email_send_rate_limit elsewhere is rateLimited', () {
expect(
mapAuthError(
_api('over_email_send_rate_limit', status: '429'),
AuthOperation.updatePassword,
),
const AuthFailure.rateLimited(),
);
});
test('KTO-01 Kriterium 7: over_request_rate_limit -> rateLimited', () {
expect(
mapAuthError(
_api('over_request_rate_limit', status: '429'),
AuthOperation.signIn,
),
const AuthFailure.rateLimited(),
);
});
test('any other HTTP 429 (also non-JSON body) -> rateLimited', () {
expect(
mapAuthError(
const AuthApiException('slow down', statusCode: '429'),
AuthOperation.signIn,
),
const AuthFailure.rateLimited(),
);
});
test('KTO-01 Kriterium 3: AuthWeakPasswordException with reasons', () {
expect(
mapAuthError(
AuthWeakPasswordException(
message: 'weak',
statusCode: '422',
reasons: const ['length', 'characters', 'pwned', 'other'],
),
AuthOperation.signUp,
),
const AuthFailure.weakPassword([
WeakPasswordReason.length,
WeakPasswordReason.characters,
WeakPasswordReason.pwned,
WeakPasswordReason.unknown,
]),
);
});
test('weak_password code on a plain API exception', () {
expect(
mapAuthError(
_api('weak_password', status: '422'),
AuthOperation.updatePassword,
),
const AuthFailure.weakPassword([]),
);
});
test('same_password', () {
expect(
mapAuthError(
_api('same_password', status: '422'),
AuthOperation.updatePassword,
),
const AuthFailure.samePassword(),
);
});
test('KTO-01 Kriterium 8: otp_expired -> linkInvalidOrExpired', () {
expect(
mapAuthError(
_api('otp_expired', status: '403'),
AuthOperation.verifyLink,
),
const AuthFailure.linkInvalidOrExpired(),
);
});
test('KTO-01 Kriterium 8: 403 without known code on verifyOTP', () {
expect(
mapAuthError(
const AuthApiException('invalid', statusCode: '403'),
AuthOperation.verifyLink,
),
const AuthFailure.linkInvalidOrExpired(),
);
expect(
mapAuthError(
const AuthApiException('forbidden', statusCode: '403'),
AuthOperation.signIn,
),
const AuthFailure.unexpected(),
);
});
test('email_address_invalid and validation_failed -> invalidEmail', () {
expect(
mapAuthError(_api('email_address_invalid'), AuthOperation.signUp),
const AuthFailure.invalidEmail(),
);
expect(
mapAuthError(
_api('validation_failed', status: '422'),
AuthOperation.requestPasswordReset,
),
const AuthFailure.invalidEmail(),
);
});
test('KTO-01 Kriterium 2, 10: HTTP 500 on signUp (trigger error) -> '
'registrationRejected, decided by status only', () {
final error = AuthRetryableFetchException(
message: _triggerBody,
statusCode: '500',
);
expect(error.code, isNull);
expect(
mapAuthError(error, AuthOperation.signUp),
const AuthFailure.registrationRejected(
reason: RegistrationRejectionReason.displayName,
),
);
});
test('F13: HTTP 500 on signUp with unknown body (SMTP) -> unexpected', () {
expect(
mapAuthError(
AuthRetryableFetchException(
message: '{"code":"unexpected_failure","msg":"Error sending"}',
statusCode: '500',
),
AuthOperation.signUp,
),
const AuthFailure.unexpected(),
);
});
test('F13: HTTP 5xx on reset and resend -> unexpected, not network', () {
for (final op in [
AuthOperation.requestPasswordReset,
AuthOperation.resend,
]) {
for (final status in ['500', '502', '504']) {
expect(
mapAuthError(
AuthRetryableFetchException(message: 'x', statusCode: status),
op,
),
const AuthFailure.unexpected(),
reason: '$op $status',
);
}
}
});
test('F13: a transport error without status stays network', () {
for (final op in [
AuthOperation.signUp,
AuthOperation.requestPasswordReset,
AuthOperation.resend,
]) {
expect(
mapAuthError(AuthRetryableFetchException(message: 'x'), op),
const AuthFailure.network(),
);
}
});
test('trigger messages map to reasons', () {
expect(
registrationRejectionReasonFrom('age_confirmation_required'),
RegistrationRejectionReason.ageConfirmation,
);
expect(
registrationRejectionReasonFrom('terms_acceptance_required'),
RegistrationRejectionReason.termsAcceptance,
);
expect(
registrationRejectionReasonFrom('terms_version_required'),
RegistrationRejectionReason.termsVersion,
);
});
test('HTTP 500 outside signUp is a network failure', () {
expect(
mapAuthError(
AuthRetryableFetchException(message: _triggerBody, statusCode: '500'),
AuthOperation.signIn,
),
const AuthFailure.network(),
);
});
test('F13: other 5xx on signUp (gateway) -> unexpected (neutral)', () {
expect(
mapAuthError(
AuthRetryableFetchException(
message: 'bad gateway',
statusCode: '502',
),
AuthOperation.signUp,
),
const AuthFailure.unexpected(),
);
});
test('Zustände Krit. 2: AuthRetryableFetchException without status', () {
expect(
mapAuthError(
AuthRetryableFetchException(message: 'Failed host lookup'),
AuthOperation.signIn,
),
const AuthFailure.network(),
);
});
test('TimeoutException and raw transport errors -> network', () {
expect(
mapAuthError(TimeoutException('t'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(const SocketException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(http.ClientException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(const HandshakeException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
});
test('everything else -> unexpected', () {
expect(
mapAuthError(StateError('x'), AuthOperation.signIn),
const AuthFailure.unexpected(),
);
expect(
mapAuthError(_api('something_new'), AuthOperation.signIn),
const AuthFailure.unexpected(),
);
expect(
mapAuthError(
AuthSessionMissingException(),
AuthOperation.updatePassword,
),
const AuthFailure.unexpected(),
);
});
});
}