import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:kooltap/services/pin_lock_service.dart'; import 'helpers/memory_secure_storage.dart'; 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; setUp(() { storage = MemorySecureStorage(); now = DateTime(2026, 10, 7, 12); service = PinLockService(storage: storage, clock: () => now); }); Future 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 { expect(await service.hasPin(), false); }); test('returns false when hash is empty', () async { storage.values['pin_hash'] = ''; expect(await service.hasPin(), false); }); test('returns true once a pin is set', () async { await service.setPin('1234'); expect(await service.hasPin(), true); }); }); group('setPin', () { test('stores a salted PBKDF2 hash', () async { await service.setPin('1234'); 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 { await service.setPin('1234'); final firstSalt = storage.values['pin_salt']; await service.setPin('5678'); expect(storage.values['pin_salt'], isNot(firstSalt)); }); }); group('verifyPin', () { test('returns false when salt is missing', () async { storage.values['pin_hash'] = 'somehash'; expect(await service.verifyPin('1234'), false); }); test('returns false when hash is missing', () async { storage.values['pin_salt'] = 'somesalt'; expect(await service.verifyPin('1234'), false); }); test('returns true for correct pin', () async { await service.setPin('1234'); expect(await service.verifyPin('1234'), true); }); test('returns false for incorrect pin', () async { await service.setPin('1234'); expect(await service.verifyPin('5678'), 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()), ); }); 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()), ); 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 the pin and any lockout', () async { await service.setPin('1234'); await failTimes(5); await service.clearPin(); expect(storage.values, isEmpty); }); }); }); }