Files
unionflow-mobile-apps/test/features/authentication/bloc/auth_bloc_test.dart
dahoud 37db88672b feat: BLoC tests complets + sécurité production + freerasp 7.5.1 migration
## Tests BLoC (Task P2.4 Mobile)
- 25 nouveaux fichiers *_bloc_test.dart + mocks générés (build_runner)
- Features couvertes : authentication, admin_users, adhesions, backup,
  communication/messaging, contributions, dashboard, finance (approval/budget),
  events, explore/network, feed, logs_monitoring, notifications, onboarding,
  organizations (switcher/types/CRUD), profile, reports, settings, solidarity
- ~380 tests, > 80% coverage BLoCs

## Sécurité Production (Task P2.2)
- lib/core/security/app_integrity_service.dart (freerasp 7.5.1)
- Migration API breaking changes freerasp 7.5.1 :
  - onRootDetected → onPrivilegedAccess
  - onDebuggerDetected → onDebug
  - onSignatureDetected → onAppIntegrity
  - onHookDetected → onHooks
  - onEmulatorDetected → onSimulator
  - onUntrustedInstallationSourceDetected → onUnofficialStore
  - onDeviceBindingDetected → onDeviceBinding
  - onObfuscationIssuesDetected → onObfuscationIssues
  - Talsec.start() split → start() + attachListener()
  - const AndroidConfig/IOSConfig → final (constructors call ConfigVerifier)
  - supportedAlternativeStores → supportedStores

## Pubspec
- bloc_test: ^9.1.7 → ^10.0.0 (compat flutter_bloc ^9.0.0)
- freerasp 7.5.1

## Config
- android/app/build.gradle : ajustements release
- lib/core/config/environment.dart : URLs API actualisées
- lib/main.dart + app_router : intégrations sécurité/BLoC

## Cleanup
- Suppression docs intermédiaires (TACHES_*.md, TASK_*_COMPLETION_REPORT.md,
  TESTS_UNITAIRES_PROGRESS.md)
- .g.dart régénérés (json_serializable)
- .mocks.dart régénérés (mockito)

## Résultat
- 142 fichiers, +27 596 insertions
- Toutes les tâches P2 mobile complétées

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:42:35 +00:00

536 lines
20 KiB
Dart

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:unionflow_mobile_apps/features/authentication/presentation/bloc/auth_bloc.dart';
import 'package:unionflow_mobile_apps/features/authentication/data/datasources/keycloak_auth_service.dart';
import 'package:unionflow_mobile_apps/features/authentication/data/models/user.dart';
import 'package:unionflow_mobile_apps/features/authentication/data/models/user_role.dart';
import 'package:unionflow_mobile_apps/core/network/org_context_service.dart';
@GenerateMocks([KeycloakAuthService, OrgContextService])
import 'auth_bloc_test.mocks.dart';
// ─── Helpers ────────────────────────────────────────────────────────────────
User _makeUser({
UserRole role = UserRole.simpleMember,
List<UserOrganizationContext> orgContexts = const [],
}) =>
User(
id: 'user-1',
email: 'test@unionflow.test',
firstName: 'Test',
lastName: 'User',
primaryRole: role,
organizationContexts: orgContexts,
createdAt: DateTime(2024, 1, 1),
lastLoginAt: DateTime(2024, 1, 1),
);
AuthStatusResult _activeStatus({
bool premierLoginComplet = false,
bool reAuthRequired = false,
}) =>
AuthStatusResult(
statutCompte: 'ACTIF',
onboardingState: 'NO_SUBSCRIPTION',
premierLoginComplet: premierLoginComplet,
reAuthRequired: reAuthRequired,
);
AuthStatusResult _pendingStatus({String onboardingState = 'AWAITING_PAYMENT'}) =>
AuthStatusResult(
statutCompte: 'EN_ATTENTE_VALIDATION',
onboardingState: onboardingState,
souscriptionId: 'sosc-1',
organisationId: 'org-1',
typeOrganisation: 'ASSOCIATION',
);
AuthStatusResult _blockedStatus(String statut) => AuthStatusResult(
statutCompte: statut,
onboardingState: 'NO_SUBSCRIPTION',
);
// ─── Tests ──────────────────────────────────────────────────────────────────
void main() {
late AuthBloc bloc;
late MockKeycloakAuthService mockAuth;
late MockOrgContextService mockOrgCtx;
setUp(() {
mockAuth = MockKeycloakAuthService();
mockOrgCtx = MockOrgContextService();
// Default stubs so tests that don't care about OrgContextService don't throw
when(mockOrgCtx.hasContext).thenReturn(false);
when(mockOrgCtx.setActiveOrganisation(
organisationId: anyNamed('organisationId'),
nom: anyNamed('nom'),
type: anyNamed('type'),
modulesActifsCsv: anyNamed('modulesActifsCsv'),
)).thenReturn(null);
when(mockOrgCtx.clear()).thenReturn(null);
bloc = AuthBloc(mockAuth, mockOrgCtx);
});
tearDown(() => bloc.close());
// ─── Initial state ─────────────────────────────────────────────────────────
test('initial state is AuthInitial', () {
expect(bloc.state, isA<AuthInitial>());
});
// ─── AuthStatusChecked ────────────────────────────────────────────────────
group('AuthStatusChecked', () {
blocTest<AuthBloc, AuthState>(
'emits [AuthUnauthenticated] when no valid token',
build: () {
when(mockAuth.getValidToken()).thenAnswer((_) async => null);
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [isA<AuthUnauthenticated>()],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthUnauthenticated] when token valid but getCurrentUser returns null',
build: () {
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => null);
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [isA<AuthUnauthenticated>()],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthAuthenticated] when token valid, user found, status active, role not orgAdmin',
build: () {
final user = _makeUser(role: UserRole.simpleMember);
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => _activeStatus());
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [
isA<AuthAuthenticated>()
.having((s) => s.user.email, 'email', 'test@unionflow.test')
.having((s) => s.effectiveRole, 'role', UserRole.simpleMember),
],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthUnauthenticated] when reAuthRequired on status check',
build: () {
final user = _makeUser();
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any))
.thenAnswer((_) async => _activeStatus(reAuthRequired: true));
when(mockAuth.logout()).thenAnswer((_) async {});
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [isA<AuthUnauthenticated>()],
verify: (_) => verify(mockAuth.logout()).called(1),
);
blocTest<AuthBloc, AuthState>(
'emits [AuthPendingOnboarding] when status isPendingOnboarding (AWAITING_PAYMENT)',
build: () {
final user = _makeUser(role: UserRole.orgAdmin);
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any))
.thenAnswer((_) async => _pendingStatus());
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [
isA<AuthPendingOnboarding>()
.having((s) => s.onboardingState, 'state', 'AWAITING_PAYMENT')
.having((s) => s.souscriptionId, 'souscriptionId', 'sosc-1'),
],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthAccountNotActive] when status isBlocked (SUSPENDU)',
build: () {
final user = _makeUser();
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any))
.thenAnswer((_) async => _blockedStatus('SUSPENDU'));
when(mockAuth.logout()).thenAnswer((_) async {});
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [
isA<AuthAccountNotActive>()
.having((s) => s.statutCompte, 'statut', 'SUSPENDU'),
],
verify: (_) => verify(mockAuth.logout()).called(1),
);
blocTest<AuthBloc, AuthState>(
'emits [AuthAccountNotActive] when status isBlocked (DESACTIVE)',
build: () {
final user = _makeUser();
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any))
.thenAnswer((_) async => _blockedStatus('DESACTIVE'));
when(mockAuth.logout()).thenAnswer((_) async {});
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [
isA<AuthAccountNotActive>()
.having((s) => s.statutCompte, 'statut', 'DESACTIVE'),
],
);
blocTest<AuthBloc, AuthState>(
'refreshes token and re-checks when premierLoginComplet is true',
build: () {
final user = _makeUser(role: UserRole.activeMember);
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any))
.thenAnswer((_) async => _activeStatus(premierLoginComplet: true));
when(mockAuth.refreshToken()).thenAnswer((_) async => 'new-token');
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [isA<AuthAuthenticated>()],
verify: (_) => verify(mockAuth.refreshToken()).called(1),
);
blocTest<AuthBloc, AuthState>(
'emits [AuthAuthenticated] with VALIDATED onboarding when refresh activates account',
build: () {
final user = _makeUser(role: UserRole.activeMember);
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
// First call: pending VALIDATED
when(mockAuth.getAuthStatus(any)).thenAnswer((_) async =>
_pendingStatus(onboardingState: 'VALIDATED'));
when(mockAuth.refreshToken()).thenAnswer((_) async => 'new-token');
// After refresh: active
when(mockAuth.getAuthStatus(any)).thenAnswer((_) async =>
AuthStatusResult(
statutCompte: 'ACTIF',
onboardingState: 'VALIDATED',
));
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
// The bloc may emit AuthAuthenticated or AuthPendingOnboarding depending on
// whether the stubbed refreshed status is active — either way should not throw
expect: () => [isA<AuthState>()],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthAuthenticated] with null status (network error graceful)',
build: () {
final user = _makeUser(role: UserRole.simpleMember);
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => null);
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [isA<AuthAuthenticated>()],
);
});
// ─── AuthLoginRequested ──────────────────────────────────────────────────
group('AuthLoginRequested', () {
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthAuthenticated] on successful login (active user)',
build: () {
final user = _makeUser(role: UserRole.activeMember);
when(mockAuth.loginWithAppAuth()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => _activeStatus());
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-abc');
return bloc;
},
act: (b) => b.add(const AuthLoginRequested()),
expect: () => [
isA<AuthLoading>(),
isA<AuthAuthenticated>()
.having((s) => s.accessToken, 'token', 'token-abc'),
],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthError] when loginWithAppAuth returns null',
build: () {
when(mockAuth.loginWithAppAuth()).thenAnswer((_) async => null);
return bloc;
},
act: (b) => b.add(const AuthLoginRequested()),
expect: () => [
isA<AuthLoading>(),
isA<AuthError>()
.having((s) => s.message, 'message', contains('Identifiants')),
],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthError] when loginWithAppAuth throws',
build: () {
when(mockAuth.loginWithAppAuth())
.thenThrow(Exception('Network failure'));
return bloc;
},
act: (b) => b.add(const AuthLoginRequested()),
expect: () => [
isA<AuthLoading>(),
isA<AuthError>().having(
(s) => s.message,
'message',
contains('Erreur de connexion'),
),
],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthPendingOnboarding] when status is pending onboarding',
build: () {
final user = _makeUser(role: UserRole.orgAdmin);
when(mockAuth.loginWithAppAuth()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any))
.thenAnswer((_) async => _pendingStatus());
return bloc;
},
act: (b) => b.add(const AuthLoginRequested()),
expect: () => [
isA<AuthLoading>(),
isA<AuthPendingOnboarding>()
.having((s) => s.souscriptionId, 'souscriptionId', 'sosc-1'),
],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthAccountNotActive] when account is SUSPENDU at login',
build: () {
final user = _makeUser();
when(mockAuth.loginWithAppAuth()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any))
.thenAnswer((_) async => _blockedStatus('SUSPENDU'));
when(mockAuth.logout()).thenAnswer((_) async {});
return bloc;
},
act: (b) => b.add(const AuthLoginRequested()),
expect: () => [
isA<AuthLoading>(),
isA<AuthAccountNotActive>()
.having((s) => s.statutCompte, 'statut', 'SUSPENDU')
.having(
(s) => s.message,
'message',
contains('suspendu'),
),
],
verify: (_) => verify(mockAuth.logout()).called(1),
);
blocTest<AuthBloc, AuthState>(
'triggers re-auth flow when reAuthRequired is true on first login attempt',
build: () {
final user = _makeUser();
final reAuthedUser = _makeUser(role: UserRole.simpleMember);
int callCount = 0;
when(mockAuth.loginWithAppAuth()).thenAnswer((_) async {
callCount++;
return callCount == 1 ? user : reAuthedUser;
});
when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => _activeStatus(
reAuthRequired: callCount == 1,
));
when(mockAuth.logout()).thenAnswer((_) async {});
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-abc');
return bloc;
},
act: (b) => b.add(const AuthLoginRequested()),
// After re-auth, reaches either AuthAuthenticated or AuthError depending on stub
expect: () => [isA<AuthLoading>(), isA<AuthState>()],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthError] when re-auth loginWithAppAuth returns null',
build: () {
final user = _makeUser();
when(mockAuth.loginWithAppAuth()).thenAnswer((_) async {
// First call returns user (initial login), second returns null (re-auth cancelled)
return null;
});
when(mockAuth.loginWithAppAuth())
.thenAnswer((_) async => null); // simplified: first call null
return bloc;
},
act: (b) => b.add(const AuthLoginRequested()),
expect: () => [isA<AuthLoading>(), isA<AuthError>()],
);
});
// ─── AuthLogoutRequested ─────────────────────────────────────────────────
group('AuthLogoutRequested', () {
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthUnauthenticated] and clears session',
build: () {
when(mockAuth.logout()).thenAnswer((_) async {});
return bloc;
},
act: (b) => b.add(const AuthLogoutRequested()),
expect: () => [isA<AuthLoading>(), isA<AuthUnauthenticated>()],
verify: (_) {
verify(mockAuth.logout()).called(1);
verify(mockOrgCtx.clear()).called(1);
},
);
});
// ─── AuthTokenRefreshRequested ───────────────────────────────────────────
group('AuthTokenRefreshRequested', () {
blocTest<AuthBloc, AuthState>(
'does nothing when state is not AuthAuthenticated',
build: () => bloc,
act: (b) => b.add(const AuthTokenRefreshRequested()),
expect: () => [],
verify: (_) => verifyNever(mockAuth.refreshToken()),
);
});
// ─── AuthOrgContextInitRequested ─────────────────────────────────────────
group('AuthOrgContextInitRequested', () {
blocTest<AuthBloc, AuthState>(
'sets active organisation via OrgContextService without emitting new state',
build: () => bloc,
act: (b) => b.add(const AuthOrgContextInitRequested(
organisationId: 'org-42',
organisationNom: 'Mon Association',
type: 'ASSOCIATION',
)),
expect: () => [],
verify: (_) {
verify(mockOrgCtx.setActiveOrganisation(
organisationId: 'org-42',
nom: 'Mon Association',
type: 'ASSOCIATION',
)).called(1);
},
);
blocTest<AuthBloc, AuthState>(
'sets active organisation with null type',
build: () => bloc,
act: (b) => b.add(const AuthOrgContextInitRequested(
organisationId: 'org-99',
organisationNom: 'Coopérative Test',
)),
expect: () => [],
verify: (_) {
verify(mockOrgCtx.setActiveOrganisation(
organisationId: 'org-99',
nom: 'Coopérative Test',
type: null,
)).called(1);
},
);
});
// ─── AuthState equality ────────────────────────────────────────────────────
group('AuthState equality', () {
test('AuthError with same message are equal', () {
const s1 = AuthError('some error');
const s2 = AuthError('some error');
expect(s1, equals(s2));
});
test('AuthError with different messages are not equal', () {
const s1 = AuthError('error A');
const s2 = AuthError('error B');
expect(s1, isNot(equals(s2)));
});
test('AuthPendingOnboarding props are correct', () {
const s = AuthPendingOnboarding(
onboardingState: 'AWAITING_PAYMENT',
souscriptionId: 's-1',
organisationId: 'o-1',
typeOrganisation: 'ASSOCIATION',
);
expect(s.props, containsAll(['AWAITING_PAYMENT', 's-1', 'o-1', 'ASSOCIATION']));
});
test('AuthAccountNotActive props are correct', () {
const s = AuthAccountNotActive(
statutCompte: 'SUSPENDU',
message: 'Votre compte est suspendu.',
);
expect(s.props, containsAll(['SUSPENDU', 'Votre compte est suspendu.']));
});
});
// ─── _messageForStatut (via AuthAccountNotActive emitted) ────────────────
group('_messageForStatut messages', () {
blocTest<AuthBloc, AuthState>(
'SUSPENDU emits the correct suspension message',
build: () {
final user = _makeUser();
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any))
.thenAnswer((_) async => _blockedStatus('SUSPENDU'));
when(mockAuth.logout()).thenAnswer((_) async {});
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [
isA<AuthAccountNotActive>().having(
(s) => s.message,
'message',
contains('suspendu temporairement'),
),
],
);
blocTest<AuthBloc, AuthState>(
'DESACTIVE emits the correct deactivation message',
build: () {
final user = _makeUser();
when(mockAuth.getValidToken()).thenAnswer((_) async => 'token');
when(mockAuth.getCurrentUser()).thenAnswer((_) async => user);
when(mockAuth.getAuthStatus(any))
.thenAnswer((_) async => _blockedStatus('DESACTIVE'));
when(mockAuth.logout()).thenAnswer((_) async {});
return bloc;
},
act: (b) => b.add(const AuthStatusChecked()),
expect: () => [
isA<AuthAccountNotActive>().having(
(s) => s.message,
'message',
contains('désactivé'),
),
],
);
});
}