Add reusable two-factor verification for login and money operations (#32)
This commit is contained in:
1 parent
b7a58b1a87
commit
fb40f860d2
41 files changed
+2439
-43
No files matched your search
@@ -0,0 +1,212 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user