import 'package:flutter_test/flutter_test.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart'; void main() { late DateTime now; late DummyTwoFactorService service; setUp(() { now = DateTime(2026, 9, 12); service = DummyTwoFactorService(delay: Duration.zero, now: () => now); }); Matcher error(FactorError code) => throwsA(isA().having((e) => e.code, 'code', code)); Future grant(FactorPurpose purpose, String op) async { final c = (await service.begin(purpose, op))!; return service.verify(c.id, '123456'); } test( 'protection is off until enrollment code and setting update complete', () async { final c = (await service.begin(FactorPurpose.enable, 'enable'))!; expect((await service.fetch()).enabled, false); await expectLater( service.verify(c.id, '000000'), error(FactorError.invalid), ); expect((await service.fetch()).enabled, false); final g = await service.verify(c.id, '123456'); expect((await service.fetch()).enabled, false); expect((await service.setEnabled(true, g.token)).enabled, true); await expectLater( service.setEnabled(false, g.token), error(FactorError.required), ); final off = await grant(FactorPurpose.disable, 'disable'); expect((await service.setEnabled(false, off.token)).enabled, false); }, ); test('grant is scope bound, one-use and expires', () async { final g = await grant(FactorPurpose.enable, 'enable'); await expectLater( service.authorize(FactorPurpose.disable, 'disable', g.token), error(FactorError.required), ); await expectLater( service.authorize(FactorPurpose.enable, 'other', g.token), error(FactorError.required), ); now = now.add(const Duration(minutes: 6)); await expectLater( service.authorize(FactorPurpose.enable, 'enable', g.token), error(FactorError.expired), ); }); test('resend has cooldown and invalidates old challenge', () async { final a = (await service.begin(FactorPurpose.enable, 'enable'))!; await expectLater(service.resend(a.id), error(FactorError.cooldown)); await expectLater( service.begin(FactorPurpose.enable, 'enable'), error(FactorError.cooldown), ); now = now.add(const Duration(seconds: 31)); final b = await service.resend(a.id); await expectLater( service.verify(a.id, '123456'), error(FactorError.expired), ); expect((await service.verify(b.id, '123456')).token, isNotEmpty); }); test( 'attempt limit survives resend and resets only after lock expires', () async { var c = (await service.begin(FactorPurpose.enable, 'enable'))!; for (var i = 0; i < 4; i++) { await expectLater( service.verify(c.id, '000000'), error(FactorError.invalid), ); } now = now.add(const Duration(seconds: 31)); c = await service.resend(c.id); await expectLater( service.verify(c.id, '000000'), error(FactorError.locked), ); await expectLater( service.verify(c.id, '123456'), error(FactorError.locked), ); await expectLater( service.begin(FactorPurpose.enable, 'enable'), error(FactorError.locked), ); now = now.add(const Duration(minutes: 6)); c = (await service.begin(FactorPurpose.enable, 'enable'))!; expect((await service.verify(c.id, '123456')).token, isNotEmpty); }, ); test('verification retry recovers the same unconsumed grant', () async { final c = (await service.begin(FactorPurpose.enable, 'enable'))!; final a = await service.verify(c.id, '123456'); final b = await service.verify(c.id, '123456'); expect(a.token, b.token); await service.setEnabled(true, a.token); await expectLater( service.verify(c.id, '123456'), error(FactorError.expired), ); }); test( 'credential acceptance is pending until factor completes login', () async { service = DummyTwoFactorService(enabled: true, delay: Duration.zero); final login = LoginProvider( GuardedLoginService( const DummyLoginService(delay: Duration.zero), service, ), ); addTearDown(login.dispose); await login.login( email: 'person@example.com', password: 'secret', locale: 'en', ); expect(login.state.status, AuthStatus.challenge); expect(login.state.result!.userId, isEmpty); final c = login.state.result!.challenge!; final g = await service.verify(c.id, '123456'); await login.complete(c, g); expect(login.state.status, AuthStatus.success); expect(login.state.result!.challenge, null); }, ); test( 'login without enabled factor retains original success contract', () async { final login = LoginProvider( GuardedLoginService( const DummyLoginService(delay: Duration.zero), service, ), ); addTearDown(login.dispose); await login.login( email: 'person@example.com', password: 'secret', locale: 'en', ); expect(login.state.status, AuthStatus.success); }, ); test('stub factor policy cannot report a production login outcome', () async { final guarded = GuardedLoginService(_AcceptedCredentials(), service); final result = await guarded.login( const LoginRequest( email: 'person@example.com', password: 'secret', locale: 'en', ), ); expect(result.isSimulated, true); expect(guarded.isSimulated, true); }); test('provider ignores verification result after disposal', () async { final c = (await service.begin(FactorPurpose.enable, 'enable'))!; final recorder = JourneyRecorder(DummyAnalyticsService()); addTearDown(recorder.dispose); final provider = TwoFactorProvider( service, c, recorder: recorder, journey: JourneyKind.security, now: () => now, ); final pending = provider.submit('123456'); provider.dispose(); await pending; expect(provider.result, null); }); test( 'authenticator challenge is method aware and cannot resend email', () async { service = DummyTwoFactorService( enabled: true, delay: Duration.zero, method: FactorMethod.authenticator, ); final c = (await service.begin(FactorPurpose.login, 'login'))!; expect(c.method, FactorMethod.authenticator); await expectLater(service.resend(c.id), error(FactorError.required)); }, ); test('service errors never toggle protection', () async { final g = await grant(FactorPurpose.enable, 'enable'); service.nextError = FactorError.unavailable; await expectLater( service.setEnabled(true, g.token), error(FactorError.unavailable), ); expect((await service.fetch()).enabled, false); }); } class _AcceptedCredentials implements LoginService { @override Future login(LoginRequest request) async => const AuthResult(userId: 'account', isSimulated: false); }