Three StaffUser constants carried plaintext PINs (1234/2345/3456) in auth_controller.dart. Every build shipped every till's credentials, readable by anyone who unzipped the APK. Across 100 deployed devices that is one credential, not a hundred. - Schema v5 adds a staff table. Only a PBKDF2-HMAC-SHA256 hash and a per-user random salt are stored; the PIN itself exists nowhere, including there. 12,000 iterations, tuned so one sign-in is imperceptible while working through all 10,000 four-digit PINs against a stolen database takes ~15 minutes per account instead of milliseconds. - Verification is constant-time. String == returns at the first differing byte, and that timing leaks how much of a guess was right. - StaffUser no longer has a pin field at all, so the credential cannot drift back into memory, into widgets, or into a const declaration. - Weak PINs are refused: under four digits, non-numeric, repeated digits, and sequences. Two staff cannot share a PIN — the till identifies a cashier by PIN alone, so a shared one would attribute bills to whichever row was checked first. - The last admin cannot be demoted or deactivated. A till with no admin cannot be administered, including to appoint one, and recovering means editing the database by hand. - Staff are deactivated, never deleted, so bills already rung keep naming a real person. Seed accounts are now 4821/5093/6274 rather than 1234/2345/3456 — the weak-PIN rule refuses the old ones, and a default the rule itself would reject is not a defensible default. All three are flagged must-change-pin so they get a shop trading on day one without becoming permanent. Store details are now editable data, not compile-time constants. Name, address, GSTIN and phone persist to the database and are read back rather than falling through to the build's constants, which would silently undo a failed save. GSTIN is format-validated including the state code — it prints on every invoice as a legal requirement, so a typo is a compliance problem across hundreds of bills before anyone notices. Tests: 141 -> 160. Includes a test that reads every column of every staff row and asserts no seed PIN appears anywhere in the database. Migration test now asserts v5 and that an upgraded terminal comes up with the staff table present but empty — seeding is the store's job on first open, so an existing shop is never handed accounts it did not create. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
8.1 KiB
Dart
211 lines
8.1 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nearle_pos/data/local/app_database.dart';
|
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
|
|
/// A terminal already in the field is running the v3 schema. Upgrading it must
|
|
/// carry the day's archived takings across rather than dropping them, so this
|
|
/// builds a genuine v3 database and opens it with the current code.
|
|
void main() {
|
|
late Directory dir;
|
|
late String dbPath;
|
|
|
|
setUpAll(sqfliteFfiInit);
|
|
|
|
setUp(() async {
|
|
dir = await Directory.systemTemp.createTemp('nearle_migration');
|
|
dbPath = '${dir.path}/nearle_pos.db';
|
|
databaseFactory = databaseFactoryFfi;
|
|
});
|
|
|
|
tearDown(() async {
|
|
await AppDatabase.instance.close();
|
|
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
|
});
|
|
|
|
/// The schema exactly as v3 shipped it.
|
|
Future<void> createV3Database() async {
|
|
final db = await databaseFactory.openDatabase(
|
|
dbPath,
|
|
options: OpenDatabaseOptions(
|
|
version: 3,
|
|
onCreate: (db, _) async {
|
|
await db.execute('''
|
|
CREATE TABLE products (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, barcode TEXT NOT NULL,
|
|
sku TEXT NOT NULL, category TEXT NOT NULL, price REAL NOT NULL,
|
|
mrp REAL, stock REAL NOT NULL DEFAULT 0, emoji TEXT,
|
|
image_url TEXT, unit TEXT NOT NULL DEFAULT 'piece',
|
|
gst_rate REAL NOT NULL DEFAULT 0.18, hsn_code TEXT, brand TEXT,
|
|
is_active INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL)
|
|
''');
|
|
await db.execute('''
|
|
CREATE TABLE customers (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, mobile TEXT NOT NULL,
|
|
email TEXT, gender TEXT NOT NULL DEFAULT 'unspecified',
|
|
date_of_birth INTEGER, loyalty_points INTEGER NOT NULL DEFAULT 0,
|
|
lifetime_spend REAL NOT NULL DEFAULT 0,
|
|
visit_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER,
|
|
last_visit_at INTEGER)
|
|
''');
|
|
await db.execute('''
|
|
CREATE TABLE orders (
|
|
id TEXT PRIMARY KEY, invoice_number TEXT NOT NULL UNIQUE,
|
|
created_at INTEGER NOT NULL, business_date TEXT NOT NULL,
|
|
cashier_name TEXT NOT NULL, terminal_id TEXT NOT NULL,
|
|
customer_id TEXT, customer_mobile TEXT, customer_name TEXT,
|
|
subtotal REAL NOT NULL, line_discount REAL NOT NULL DEFAULT 0,
|
|
bill_discount REAL NOT NULL DEFAULT 0,
|
|
loyalty_value REAL NOT NULL DEFAULT 0,
|
|
taxable_amount REAL NOT NULL DEFAULT 0,
|
|
tax_amount REAL NOT NULL DEFAULT 0,
|
|
round_off REAL NOT NULL DEFAULT 0, total REAL NOT NULL,
|
|
points_earned INTEGER NOT NULL DEFAULT 0,
|
|
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
|
payments_json TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'completed',
|
|
sync_status INTEGER NOT NULL DEFAULT 0, synced_at INTEGER,
|
|
sync_attempts INTEGER NOT NULL DEFAULT 0, sync_error TEXT)
|
|
''');
|
|
await db.execute('''
|
|
CREATE TABLE order_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, order_id TEXT NOT NULL,
|
|
product_id TEXT NOT NULL, name TEXT NOT NULL,
|
|
barcode TEXT NOT NULL, sku TEXT NOT NULL, unit TEXT NOT NULL,
|
|
unit_price REAL NOT NULL, quantity REAL NOT NULL,
|
|
discount REAL NOT NULL DEFAULT 0, gst_rate REAL NOT NULL DEFAULT 0,
|
|
tax_amount REAL NOT NULL DEFAULT 0, line_total REAL NOT NULL,
|
|
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE)
|
|
''');
|
|
await db.execute('''
|
|
CREATE TABLE parked_bills (
|
|
id TEXT PRIMARY KEY, label TEXT, parked_at INTEGER NOT NULL,
|
|
cart_json TEXT NOT NULL)
|
|
''');
|
|
// v3 sync_log: no payload column.
|
|
await db.execute('''
|
|
CREATE TABLE sync_log (
|
|
id TEXT PRIMARY KEY, type TEXT NOT NULL, status TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL, synced_at INTEGER,
|
|
summary TEXT NOT NULL, error TEXT,
|
|
attempts INTEGER NOT NULL DEFAULT 0)
|
|
''');
|
|
// v3 day_archive: keyed by date alone, no cashier.
|
|
await db.execute('''
|
|
CREATE TABLE day_archive (
|
|
business_date TEXT PRIMARY KEY,
|
|
bill_count INTEGER NOT NULL DEFAULT 0,
|
|
item_count REAL NOT NULL DEFAULT 0,
|
|
gross_sales REAL NOT NULL DEFAULT 0,
|
|
tax_collected REAL NOT NULL DEFAULT 0,
|
|
discount_given REAL NOT NULL DEFAULT 0,
|
|
round_off REAL NOT NULL DEFAULT 0,
|
|
points_issued INTEGER NOT NULL DEFAULT 0,
|
|
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
|
payments_json TEXT NOT NULL DEFAULT '{}',
|
|
first_bill_at INTEGER, last_bill_at INTEGER,
|
|
synced_bills INTEGER NOT NULL DEFAULT 0)
|
|
''');
|
|
await db.execute(
|
|
'CREATE TABLE app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
await db.insert('day_archive', {
|
|
'business_date': '2026-07-30',
|
|
'bill_count': 12,
|
|
'item_count': 40.0,
|
|
'gross_sales': 8450.0,
|
|
'tax_collected': 620.5,
|
|
'discount_given': 130.0,
|
|
'round_off': 1.5,
|
|
'points_issued': 84,
|
|
'points_redeemed': 20,
|
|
'payments_json': '{"cash":5000.0,"upi":3450.0}',
|
|
'first_bill_at': 1000,
|
|
'last_bill_at': 2000,
|
|
'synced_bills': 12,
|
|
});
|
|
await db.insert('app_meta', {'key': 'invoice_sequence', 'value': '12'});
|
|
await db.close();
|
|
}
|
|
|
|
test('a v3 terminal upgrades without losing its archived takings', () async {
|
|
await createV3Database();
|
|
|
|
await AppDatabase.instance.open(overridePath: dbPath);
|
|
final db = AppDatabase.instance.db;
|
|
|
|
expect(await db.getVersion(), 5);
|
|
|
|
final rows = await db.query('day_archive');
|
|
expect(rows, hasLength(1));
|
|
|
|
final row = rows.single;
|
|
expect(row['business_date'], '2026-07-30');
|
|
expect(row['cashier_name'], '',
|
|
reason: 'rows from before per-cashier attribution get an empty name',);
|
|
|
|
// v5 adds staff. An upgraded terminal must come up with the table present
|
|
// but empty — seeding is the store's job on first open, not the migration's,
|
|
// so an existing shop is never handed accounts it did not create.
|
|
final staff = await db.query('staff');
|
|
expect(staff, isEmpty);
|
|
expect(row['bill_count'], 12);
|
|
expect(row['gross_sales'], 8450.0);
|
|
expect(row['tax_collected'], 620.5);
|
|
expect(row['payments_json'], '{"cash":5000.0,"upi":3450.0}');
|
|
expect(row['synced_bills'], 12);
|
|
|
|
// Unrelated state must be untouched by the migration.
|
|
final meta = await db.query('app_meta', where: "key = 'invoice_sequence'");
|
|
expect(meta.single['value'], '12');
|
|
});
|
|
|
|
test('the upgraded sync log accepts a payload', () async {
|
|
await createV3Database();
|
|
await AppDatabase.instance.open(overridePath: dbPath);
|
|
final db = AppDatabase.instance.db;
|
|
|
|
await db.insert('sync_log', {
|
|
'id': 'e1',
|
|
'type': 'shiftReport',
|
|
'status': 'synced',
|
|
'created_at': 1,
|
|
'summary': '3 bills uploaded',
|
|
'attempts': 1,
|
|
'payload_json': '{"invoices":["INV-1"]}',
|
|
});
|
|
|
|
final row = (await db.query('sync_log')).single;
|
|
expect(row['payload_json'], '{"invoices":["INV-1"]}');
|
|
});
|
|
|
|
test('the day archive now holds one row per cashier', () async {
|
|
await createV3Database();
|
|
await AppDatabase.instance.open(overridePath: dbPath);
|
|
final db = AppDatabase.instance.db;
|
|
|
|
for (final cashier in ['Divya', 'Rahul']) {
|
|
await db.insert('day_archive', {
|
|
'business_date': '2026-07-31',
|
|
'cashier_name': cashier,
|
|
'bill_count': 1,
|
|
'gross_sales': 100.0,
|
|
'payments_json': '{}',
|
|
});
|
|
}
|
|
|
|
final rows = await db.query(
|
|
'day_archive',
|
|
where: 'business_date = ?',
|
|
whereArgs: ['2026-07-31'],
|
|
);
|
|
expect(rows, hasLength(2),
|
|
reason: 'two cashiers on the same day must not collide',);
|
|
});
|
|
}
|