213 lines
7.1 KiB
Dart
213 lines
7.1 KiB
Dart
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<FactorException>().having((e) => e.code, 'code', code));
|
|
Future<FactorGrant> 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<AuthResult> login(LoginRequest request) async =>
|
|
const AuthResult(userId: 'account', isSimulated: false);
|
|
}
|