feat: add alot

This commit is contained in:
2026-10-08 04:12:36 +02:00
parent ccf5165417
commit fc607cb7bd
99 changed files with 4981 additions and 3655 deletions
+101 -83
View File
@@ -1,149 +1,167 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:kooltab2/services/pin_lock_service.dart';
import 'package:kooltap/services/pin_lock_service.dart';
class MockFlutterSecureStorage extends Mock
implements FlutterSecureStorage {}
import 'helpers/memory_secure_storage.dart';
String _computeHash(String pin, String salt) {
final bytes = utf8.encode('$salt:$pin');
return sha256.convert(bytes).toString();
String _legacyHash(String pin, String salt) {
return sha256.convert(utf8.encode('$salt:$pin')).toString();
}
void main() {
late MemorySecureStorage storage;
late DateTime now;
late PinLockService service;
late MockFlutterSecureStorage mockStorage;
setUp(() {
mockStorage = MockFlutterSecureStorage();
service = PinLockService(storage: mockStorage);
storage = MemorySecureStorage();
now = DateTime(2026, 10, 7, 12);
service = PinLockService(storage: storage, clock: () => now);
});
Future<void> failTimes(int count) async {
for (var attempt = 0; attempt < count; attempt++) {
await service.verifyPin('0000');
}
}
group('PinLockService', () {
group('hasPin', () {
test('returns false when no hash stored', () async {
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => null);
final result = await service.hasPin();
expect(result, false);
expect(await service.hasPin(), false);
});
test('returns false when hash is empty', () async {
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => '');
storage.values['pin_hash'] = '';
final result = await service.hasPin();
expect(result, false);
expect(await service.hasPin(), false);
});
test('returns true when hash exists', () async {
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => 'somehash');
test('returns true once a pin is set', () async {
await service.setPin('1234');
final result = await service.hasPin();
expect(result, true);
expect(await service.hasPin(), true);
});
});
group('setPin', () {
test('stores salt and hash', () async {
when(() => mockStorage.write(key: any(named: 'key'), value: any(named: 'value')))
.thenAnswer((_) async {});
test('stores a salted PBKDF2 hash', () async {
await service.setPin('1234');
verify(() => mockStorage.write(key: 'pin_salt', value: any(named: 'value'))).called(1);
verify(() => mockStorage.write(key: 'pin_hash', value: any(named: 'value'))).called(1);
expect(storage.values['pin_salt'], isNotEmpty);
expect(storage.values['pin_hash'], startsWith(r'pbkdf2-sha256$'));
expect(storage.values['pin_hash'], isNot(contains('1234')));
});
test('generates unique salts for different pins', () async {
when(() => mockStorage.write(key: any(named: 'key'), value: any(named: 'value')))
.thenAnswer((_) async {});
await service.setPin('1234');
final firstSaltCapture = verify(() => mockStorage.write(
key: 'pin_salt',
value: captureAny(named: 'value'),
)).captured.first;
final firstSalt = storage.values['pin_salt'];
await service.setPin('5678');
final secondSaltCapture = verify(() => mockStorage.write(
key: 'pin_salt',
value: captureAny(named: 'value'),
)).captured.last;
expect(firstSaltCapture, isNot(equals(secondSaltCapture)));
expect(storage.values['pin_salt'], isNot(firstSalt));
});
});
group('verifyPin', () {
test('returns false when salt is missing', () async {
when(() => mockStorage.read(key: 'pin_salt'))
.thenAnswer((_) async => null);
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => 'somehash');
storage.values['pin_hash'] = 'somehash';
final result = await service.verifyPin('1234');
expect(result, false);
expect(await service.verifyPin('1234'), false);
});
test('returns false when hash is missing', () async {
when(() => mockStorage.read(key: 'pin_salt'))
.thenAnswer((_) async => 'somesalt');
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => null);
storage.values['pin_salt'] = 'somesalt';
final result = await service.verifyPin('1234');
expect(result, false);
expect(await service.verifyPin('1234'), false);
});
test('returns true for correct pin', () async {
const salt = 'test_salt';
const pin = '1234';
final expectedHash = _computeHash(pin, salt);
when(() => mockStorage.read(key: 'pin_salt'))
.thenAnswer((_) async => salt);
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => expectedHash);
await service.setPin('1234');
final result = await service.verifyPin('1234');
expect(result, true);
expect(await service.verifyPin('1234'), true);
});
test('returns false for incorrect pin', () async {
const salt = 'test_salt';
final correctHash = _computeHash('1234', salt);
when(() => mockStorage.read(key: 'pin_salt'))
.thenAnswer((_) async => salt);
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => correctHash);
await service.setPin('1234');
final result = await service.verifyPin('5678');
expect(await service.verifyPin('5678'), false);
});
expect(result, false);
test('accepts a legacy hash and upgrades it', () async {
storage.values['pin_salt'] = 'test_salt';
storage.values['pin_hash'] = _legacyHash('1234', 'test_salt');
expect(await service.verifyPin('1234'), true);
expect(storage.values['pin_hash'], startsWith(r'pbkdf2-sha256$'));
expect(await service.verifyPin('1234'), true);
});
test('rejects a wrong pin against a legacy hash', () async {
storage.values['pin_salt'] = 'test_salt';
storage.values['pin_hash'] = _legacyHash('1234', 'test_salt');
expect(await service.verifyPin('5678'), false);
expect(storage.values['pin_hash'], _legacyHash('1234', 'test_salt'));
});
});
group('lockout', () {
test('locks after five wrong attempts', () async {
await service.setPin('1234');
await failTimes(5);
await expectLater(
service.verifyPin('1234'),
throwsA(isA<PinLockedOutException>()),
);
});
test('unlocks once the lockout has passed', () async {
await service.setPin('1234');
await failTimes(5);
now = now.add(const Duration(seconds: 31));
expect(await service.verifyPin('1234'), true);
});
test('doubles the lockout for every further failure', () async {
await service.setPin('1234');
await failTimes(5);
now = now.add(const Duration(seconds: 31));
await failTimes(1);
now = now.add(const Duration(seconds: 31));
await expectLater(
service.verifyPin('1234'),
throwsA(isA<PinLockedOutException>()),
);
now = now.add(const Duration(seconds: 30));
expect(await service.verifyPin('1234'), true);
});
test('a correct pin resets the failure count', () async {
await service.setPin('1234');
await failTimes(4);
await service.verifyPin('1234');
await failTimes(4);
expect(await service.verifyPin('1234'), true);
});
});
group('clearPin', () {
test('deletes both salt and hash', () async {
when(() => mockStorage.delete(key: any(named: 'key')))
.thenAnswer((_) async {});
test('deletes the pin and any lockout', () async {
await service.setPin('1234');
await failTimes(5);
await service.clearPin();
verify(() => mockStorage.delete(key: 'pin_salt')).called(1);
verify(() => mockStorage.delete(key: 'pin_hash')).called(1);
expect(storage.values, isEmpty);
});
});
});