195 lines
7.5 KiB
Dart
195 lines
7.5 KiB
Dart
import 'dart:io';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:motorcycle_dash/database/database.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_service.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_state.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
|
|
|
|
class _FakeAuthService implements AuthService {
|
|
final AuthUser _user;
|
|
|
|
_FakeAuthService([String uid = 'pilot_pixel_8a'])
|
|
: _user = AuthUser(
|
|
uid: uid,
|
|
email: '$uid@example.com',
|
|
displayName: 'Pixel Pilot',
|
|
isAnonymous: false,
|
|
);
|
|
|
|
@override
|
|
AuthUser? get currentUser => _user;
|
|
@override
|
|
AuthState get currentState => AuthState.authenticated(_user);
|
|
@override
|
|
Stream<AuthState> get authStateChanges => Stream.value(currentState);
|
|
@override
|
|
bool get isConfigured => true;
|
|
@override
|
|
bool get isEmailVerified => true;
|
|
|
|
@override
|
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
|
@override
|
|
Future<void> deleteCurrentUser() async {}
|
|
@override
|
|
void dispose() {}
|
|
@override
|
|
Future<void> ensureBackendIdentity() async {}
|
|
@override
|
|
Future<String?> getIdToken([bool forceRefresh = false]) async => 'fake_token';
|
|
@override
|
|
Future<void> reauthenticate({String? password}) async {}
|
|
@override
|
|
Future<void> reloadCurrentUser() async {}
|
|
@override
|
|
Future<void> sendEmailVerification() async {}
|
|
@override
|
|
Future<void> sendPasswordResetEmail(String email) async {}
|
|
@override
|
|
Future<void> signInAnonymously() async {}
|
|
@override
|
|
Future<void> signInWithEmailAndPassword(String email, String password) async {}
|
|
@override
|
|
Future<void> signInWithGoogle() async {}
|
|
@override
|
|
Future<void> signOut() async {}
|
|
}
|
|
|
|
File? _findBackupFile() {
|
|
final candidates = [
|
|
r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite',
|
|
'output/master-consolidation-20261012/backup-071303/motorsignal.sqlite',
|
|
'../backup-071303/motorsignal.sqlite',
|
|
'../../output/master-consolidation-20261012/backup-071303/motorsignal.sqlite',
|
|
];
|
|
for (final path in candidates) {
|
|
final file = File(path);
|
|
if (file.existsSync()) {
|
|
return file;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
final originalFile = _findBackupFile();
|
|
|
|
test(
|
|
'Phase 6 — Disposable Backup Verification & Outbox Recovery Gate',
|
|
() async {
|
|
expect(originalFile, isNotNull, reason: 'Original backup file must exist when test is executed');
|
|
final file = originalFile!;
|
|
|
|
// 1. Verify original SHA-256 before copy
|
|
final originalBytes = await file.readAsBytes();
|
|
final originalHash = sha256.convert(originalBytes).toString();
|
|
const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122';
|
|
expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256');
|
|
|
|
// 2. Create disposable copy in scratch directory
|
|
final scratchDir = Directory(
|
|
'${Directory.systemTemp.path}/motorsignal_backup_test_${DateTime.now().millisecondsSinceEpoch}',
|
|
);
|
|
if (!scratchDir.existsSync()) {
|
|
scratchDir.createSync(recursive: true);
|
|
}
|
|
final disposableFile = File('${scratchDir.path}/disposable_motorsignal.sqlite');
|
|
if (disposableFile.existsSync()) {
|
|
disposableFile.deleteSync();
|
|
}
|
|
await file.copy(disposableFile.path);
|
|
expect(disposableFile.existsSync(), isTrue);
|
|
|
|
// 3. Open disposable copy via Drift / SQLite
|
|
final db = AppDatabase.forTesting(NativeDatabase(disposableFile));
|
|
|
|
try {
|
|
// Integrity check
|
|
final integrityRows = await db.customSelect('PRAGMA integrity_check').get();
|
|
final integrityResult = integrityRows.first.data['integrity_check'];
|
|
expect(integrityResult, equals('ok'), reason: 'SQLite integrity must be OK');
|
|
|
|
// Count rides and samples
|
|
final rideCountRows = await db.customSelect('SELECT COUNT(*) as count FROM rides').get();
|
|
final totalRides = rideCountRows.first.data['count'] as int;
|
|
expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides');
|
|
|
|
final completedRidesRows = await db.customSelect('SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL').get();
|
|
final completedRides = completedRidesRows.first.data['count'] as int;
|
|
|
|
final samplesRows = await db.customSelect('SELECT COUNT(*) as count FROM telemetry_samples').get();
|
|
final totalSamples = samplesRows.first.data['count'] as int;
|
|
expect(totalSamples, equals(67798), reason: 'Must contain exactly 67,798 telemetry samples');
|
|
|
|
// 4. Test Foreign Account Isolation against real Pixel backup
|
|
// In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2'
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final outbox = SyncOutbox(prefs);
|
|
|
|
final fakeProvider = FakeSyncProvider(providerId: 'firebase');
|
|
final foreignAuth = _FakeAuthService('foreign_pilot_123');
|
|
|
|
final foreignSyncManager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: foreignAuth,
|
|
autoStart: false,
|
|
);
|
|
|
|
final foreignReport = await foreignSyncManager.reconcileCompletedRides();
|
|
expect(foreignReport.errorCount, equals(0));
|
|
expect(foreignReport.discoveredCompletedRides, equals(completedRides));
|
|
expect(foreignReport.foreignOwnerSkippedCount, equals(completedRides), reason: 'Foreign account must NEVER claim or touch real Pixel rides');
|
|
expect(foreignReport.totalRecovered, equals(0));
|
|
expect(outbox.count, equals(0));
|
|
|
|
// 5. Test Owner Recovery against disposable backup
|
|
final ownerAuth = _FakeAuthService('4btn4W3iOLWgfloMBYYK5hWa1hY2');
|
|
final ownerSyncManager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: ownerAuth,
|
|
autoStart: false,
|
|
);
|
|
|
|
final ownerReport = await ownerSyncManager.reconcileCompletedRides();
|
|
expect(ownerReport.errorCount, equals(0));
|
|
expect(ownerReport.discoveredCompletedRides, equals(completedRides));
|
|
expect(ownerReport.recoveredQueuedCount, equals(completedRides));
|
|
expect(ownerReport.totalRecovered, equals(completedRides));
|
|
expect(outbox.count, equals(completedRides));
|
|
|
|
// 6. Test repeated reconciliation idempotence as owner
|
|
final report2 = await ownerSyncManager.reconcileCompletedRides();
|
|
expect(report2.totalRecovered, equals(0));
|
|
expect(report2.alreadyTrackedCount, equals(completedRides));
|
|
expect(outbox.count, equals(completedRides));
|
|
|
|
} finally {
|
|
await db.close();
|
|
}
|
|
|
|
// 6. Verify original SHA-256 after operations to prove zero mutation
|
|
final postBytes = await file.readAsBytes();
|
|
final postHash = sha256.convert(postBytes).toString();
|
|
expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED');
|
|
},
|
|
skip: originalFile == null
|
|
? 'Real Pixel backup not present in this environment (e.g. CI runner)'
|
|
: null,
|
|
);
|
|
}
|