import 'package:flutter_test/flutter_test.dart'; import 'package:nearle_pos/core/security/pin_hasher.dart'; import 'package:nearle_pos/data/datasources/local_store.dart'; import 'package:nearle_pos/data/datasources/seed_data.dart'; import 'package:nearle_pos/data/local/app_database.dart'; import 'package:nearle_pos/data/local/staff_dao.dart'; import 'package:nearle_pos/data/repositories/store_repository_impl.dart'; import 'package:nearle_pos/domain/entities/store_account.dart'; /// Staff credentials used to be three `StaffUser` constants with plaintext /// PINs, which meant every shipped build carried every till's credentials — /// readable by anyone who unzipped the APK. void main() { late LocalStore store; late StaffDao staff; setUpAll(() { LocalStore.registerSeed( products: SeedData.products, customers: SeedData.customers, ); }); setUp(() async { store = LocalStore.instance; await store.reset(withCatalogue: true); staff = store.staff; }); group('hashing', () { test('the same PIN under two salts produces two different hashes', () { // Otherwise a stolen database would show at a glance which staff share a // PIN, and one cracked hash would open several accounts. final saltA = PinHasher.newSalt(); final saltB = PinHasher.newSalt(); expect(saltA, isNot(saltB)); expect(PinHasher.hash('4821', saltA), isNot(PinHasher.hash('4821', saltB))); }); test('a correct PIN verifies and a wrong one does not', () { final salt = PinHasher.newSalt(); final hash = PinHasher.hash('4821', salt); expect(PinHasher.verify('4821', salt: salt, hash: hash), isTrue); expect(PinHasher.verify('4822', salt: salt, hash: hash), isFalse); expect(PinHasher.verify('', salt: salt, hash: hash), isFalse); }); test('the hash is not the PIN in any recoverable form', () { final salt = PinHasher.newSalt(); final hash = PinHasher.hash('4821', salt); expect(hash, isNot(contains('4821'))); expect(hash.length, greaterThan(20)); }); }); group('storage', () { test('no PIN is ever written to the database', () async { // The whole point. Reading every column of every row must not turn up a // usable credential. final rows = await AppDatabase.instance.db.query(Tables.staff); expect(rows, isNotEmpty); for (final row in rows) { for (final entry in row.entries) { for (final account in StaffDao.seedAccounts) { expect( '${entry.value}', isNot(account.pin), reason: '${entry.key} holds a plaintext PIN', ); } } } }); test('the entity carries no PIN field at all', () { // Belt and braces: if StaffUser held one, it would be in memory, in every // widget holding a user, and back in the shipped binary the moment // someone declared an account as a constant again. const user = StaffUser(id: 'x', name: 'Y', role: StaffRole.cashier); expect(user.props.contains('4821'), isFalse); expect(user.toString(), isNot(contains('pin'))); }); test('seeded accounts are all flagged to change their PIN', () async { final all = await staff.all(); expect(all, hasLength(StaffDao.seedAccounts.length)); expect(all.every((s) => s.mustChangePin), isTrue, reason: 'a default PIN must not quietly become the permanent one',); }); test('seeding twice does not duplicate or reset anything', () async { final before = await staff.all(); await staff.setPin(before.first.id, '8317'); await staff.seedIfEmpty(); final after = await staff.all(); expect(after, hasLength(before.length)); expect(await staff.authenticate('8317'), isNotNull, reason: 'an upgrade must not reset a PIN someone chose',); }); }); group('authentication', () { test('a seeded PIN signs the right person in', () async { final user = await staff.authenticate('5093'); expect(user, isNotNull); expect(user!.name, 'Divya'); expect(user.role, StaffRole.manager); }); test('a wrong PIN returns nobody', () async { expect(await staff.authenticate('9999'), isNull); expect(await staff.authenticate(''), isNull); }); test('a deactivated account cannot sign in', () async { final rahul = (await staff.all()).firstWhere((s) => s.name == 'Rahul'); await staff.deactivate(rahul.id); expect(await staff.authenticate('6274'), isNull); expect(await staff.all(), isNot(contains(rahul))); }); }); group('rules', () { test('a PIN a queue could read off your hand is refused', () async { for (final weak in ['0000', '1111', '1234', '4321']) { await expectLater( staff.create(name: 'X', role: StaffRole.cashier, pin: weak), throwsA(isA()), reason: '$weak was accepted', ); } }); test('a PIN shorter than four digits, or not digits, is refused', () async { await expectLater( staff.create(name: 'X', role: StaffRole.cashier, pin: '821'), throwsA(isA()), ); await expectLater( staff.create(name: 'X', role: StaffRole.cashier, pin: 'abcd'), throwsA(isA()), ); }); test('two people cannot share a PIN', () async { // The till identifies a cashier by PIN alone, so a shared one would // attribute bills to whichever row happened to be checked first. await expectLater( staff.create(name: 'Impostor', role: StaffRole.cashier, pin: '5093'), throwsA(isA()), ); }); test('the last admin cannot be demoted or deactivated', () async { // A till with no admin cannot be administered — including to make someone // an admin again. Recovering means editing the database by hand. final all = await staff.all(); final admin = all.firstWhere((s) => s.role == StaffRole.admin); await expectLater( staff.deactivate(admin.id), throwsA(isA()), ); await expectLater( staff.updateDetails(id: admin.id, role: StaffRole.cashier), throwsA(isA()), ); // With a second admin in place, both become legal. final divya = all.firstWhere((s) => s.name == 'Divya'); await staff.updateDetails(id: divya.id, role: StaffRole.admin); await staff.deactivate(admin.id); expect((await staff.all()).any((s) => s.role == StaffRole.admin), isTrue); }); test('choosing your own PIN clears the change flag', () async { final user = (await staff.all()).first; expect(user.mustChangePin, isTrue); await staff.setPin(user.id, '8317'); final after = await staff.findById(user.id); expect(after!.mustChangePin, isFalse); expect(await staff.authenticate('8317'), isNotNull); expect(await staff.authenticate('4821'), isNull, reason: 'the old PIN must stop working',); }); test('an admin reset re-arms the change flag', () async { final user = (await staff.all()).last; await staff.setPin(user.id, '7168', mustChangePin: true); expect((await staff.findById(user.id))!.mustChangePin, isTrue); }); test('deactivating keeps the row, so old bills still name a real person', () async { final rahul = (await staff.all()).firstWhere((s) => s.name == 'Rahul'); await staff.deactivate(rahul.id); expect(await staff.findById(rahul.id), isNotNull); expect((await staff.all(includeInactive: true)).length, 3); }); }); group('store details', () { test('edits persist and are read back, not overwritten by the constants', () async { final repo = StoreRepositoryImpl(store); await repo.save( name: 'Nearle Daily — Anna Nagar', address: '12 2nd Ave, Chennai 600040', gstin: '33AABCU9603R1ZM', phone: '9840012345', ); final loaded = await repo.load(email: 'a@b.c'); expect(loaded.name, 'Nearle Daily — Anna Nagar'); expect(loaded.gstin, '33AABCU9603R1ZM'); expect(loaded.phone, '9840012345'); }); test('a malformed GSTIN is caught before it reaches an invoice', () { // These print on every bill as a legal requirement, so a typo is a // compliance problem across a few hundred invoices before anyone notices. expect(GstinValidator.validate('33AABCU9603R1ZM'), isNull); expect(GstinValidator.validate(''), isNotNull); expect(GstinValidator.validate('33AABCU9603R1Z'), isNotNull); expect(GstinValidator.validate('99AABCU9603R1ZM'), isNotNull); expect(GstinValidator.validate('33aabcu9603r1zm'), isNull, reason: 'lowercase is normalised, not rejected',); }); }); }