Files
sxchnge/frontend/packages/sxchnge_auth/test/two_factor_test.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);
}