fix(sync): harden worker metadata headers, token refresh on confirmation, and reconciliation ownership isolation

- Set X-MotorSignal-* headers on Worker GET /r2/verify responses and return 503 TEMPORARY_ERROR when Firestore ownership lookup fails with 429/5xx
This commit is contained in:
Developer 2026-10-14 11:27:46 -04:00
parent cff4734678
commit de6d575cee
7 changed files with 304 additions and 45 deletions

View File

@ -156,6 +156,7 @@ export default {
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400); if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken); const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error' }, 503);
if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403); if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403);
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404); if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404);
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409); if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409);
@ -181,6 +182,7 @@ export default {
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400); if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken); const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error', code: 'TEMPORARY_ERROR' }, 503);
if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized', code: 'UNAUTHORIZED' }, 403); if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized', code: 'UNAUTHORIZED' }, 403);
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found', code: 'NOT_FOUND' }, 404); if (ride === 'not-found') return jsonResponse({ error: 'Ride not found', code: 'NOT_FOUND' }, 404);
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid', code: 'INVALID_METADATA' }, 409); if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid', code: 'INVALID_METADATA' }, 409);
@ -204,7 +206,8 @@ export default {
return new Response(null, { status: 200, headers }); return new Response(null, { status: 200, headers });
} }
return jsonResponse({ headers.set('Content-Type', 'application/json');
return new Response(JSON.stringify({
verified: true, verified: true,
remoteRideId, remoteRideId,
objectKey, objectKey,
@ -213,7 +216,7 @@ export default {
sha256, sha256,
appVersion, appVersion,
uploadedAt, uploadedAt,
}, 200); }), { status: 200, headers });
} }
return jsonResponse({ error: 'Endpoint not found' }, 404); return jsonResponse({ error: 'Endpoint not found' }, 404);
@ -241,7 +244,7 @@ async function verifyFirebaseToken(token: string, expectedProjectId: string): Pr
} }
interface OwnedRide { objectKey?: string; } interface OwnedRide { objectKey?: string; }
type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid'; type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid' | 'temporary-error';
/** Reads the authenticated user's ride document through Firestore REST. */ /** Reads the authenticated user's ride document through Firestore REST. */
async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: string, idToken: string): Promise<RideAuthorizationResult> { async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: string, idToken: string): Promise<RideAuthorizationResult> {
@ -251,6 +254,7 @@ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: stri
if (response.status === 401 || response.status === 403) return 'unauthorized'; if (response.status === 401 || response.status === 403) return 'unauthorized';
if (response.status === 404) return 'not-found'; if (response.status === 404) return 'not-found';
if (response.status === 429 || response.status >= 500) return 'temporary-error';
if (!response.ok) { if (!response.ok) {
console.error('Firestore ownership lookup failed', response.status); console.error('Firestore ownership lookup failed', response.status);
return 'unauthorized'; return 'unauthorized';

View File

@ -337,4 +337,38 @@ describe('Worker /r2/verify endpoint', () => {
const body = await res.json() as any; const body = await res.json() as any;
expect(body.error).toBe('Internal Worker Exception'); expect(body.error).toBe('Internal Worker Exception');
}); });
it('19. returns 503 TEMPORARY_ERROR when Firestore ownership lookup fails with 429 or 503', async () => {
mockFirestore(503, { error: 'Service Unavailable' });
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(503);
const body = await res.json() as any;
expect(body.code).toBe('TEMPORARY_ERROR');
expect(body.error).toContain('Firestore temporary lookup error');
});
it('20. sets X-MotorSignal-* headers on GET as well as HEAD', async () => {
mockValidFirestore();
mockR2Head.mockResolvedValue({
size: 2048,
httpEtag: '"etag-2048"',
customMetadata: { sha256: 'sha256_get_test' },
});
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
method: 'GET',
headers: { Authorization: 'Bearer valid_token' },
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(200);
expect(res.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123');
expect(res.headers.get('X-MotorSignal-Sha256')).toBe('sha256_get_test');
expect(res.headers.get('X-MotorSignal-Size')).toBe('2048');
expect(res.headers.get('etag')).toBe('"etag-2048"');
});
}); });

View File

@ -209,13 +209,30 @@ class FirebaseSyncProvider implements SyncProvider {
); );
} }
final r2Result = await _r2UploadService.verifyTelemetryBundle( var r2Result = await _r2UploadService.verifyTelemetryBundle(
idToken: idToken, idToken: idToken,
remoteRideId: remoteRideId, remoteRideId: remoteRideId,
expectedSha256: expectedSha256, expectedSha256: expectedSha256,
expectedSize: expectedSize?.toInt(), expectedSize: expectedSize?.toInt(),
); );
// If verification is rejected as unauthorized (e.g. expired cached token),
// force refresh the ID token once and retry before concluding unauthorized.
if (r2Result.status == CloudConfirmationStatus.unauthorized) {
final refreshedToken = await _authService.getIdToken(true);
if (refreshedToken != null &&
refreshedToken.isNotEmpty &&
refreshedToken != idToken) {
idToken = refreshedToken;
r2Result = await _r2UploadService.verifyTelemetryBundle(
idToken: idToken,
remoteRideId: remoteRideId,
expectedSha256: expectedSha256,
expectedSize: expectedSize?.toInt(),
);
}
}
switch (r2Result.status) { switch (r2Result.status) {
case CloudConfirmationStatus.confirmed: case CloudConfirmationStatus.confirmed:
return CloudConfirmationResult.confirmed( return CloudConfirmationResult.confirmed(

View File

@ -267,6 +267,16 @@ class R2UploadService {
/// Lightweight verification of telemetry bundle storage and integrity in Cloudflare R2. /// Lightweight verification of telemetry bundle storage and integrity in Cloudflare R2.
/// Queries the worker via /r2/verify to verify existence, metadata, and hash match /// Queries the worker via /r2/verify to verify existence, metadata, and hash match
/// without downloading the full ZIP archive. /// without downloading the full ZIP archive.
///
/// CRITICAL ARCHITECTURAL DISTINCTION:
/// R2 HEAD / metadata checks prove that an object exists in Cloudflare R2 and that its stored
/// metadata (SHA-256 hash, byte size, object key, remote ride identity) matches the expected
/// Firestore and local contract.
/// They do NOT independently prove that the stored ZIP archive's binary payload is readable,
/// decompresses cleanly, or is uncorrupted at rest. Full bundle downloads on startup are
/// intentionally avoided to protect mobile bandwidth and startup latency. For future stronger
/// verification, background sampling or periodic full restore-integrity checks should be
/// scheduled rather than downloading every bundle on every launch or reconciliation pass.
Future<R2VerificationResponse> verifyTelemetryBundle({ Future<R2VerificationResponse> verifyTelemetryBundle({
required String idToken, required String idToken,
required String remoteRideId, required String remoteRideId,

View File

@ -345,13 +345,6 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
continue; continue;
} }
// Claim unclaimed ride if user is authenticated and target is firebase
if (ride.cloudOwnerUid == null &&
isAuthenticated &&
providerId == 'firebase') {
await _db.claimRideForCloudOwner(ride.id, user.uid);
}
// Stable cloud identity check // Stable cloud identity check
final remoteRideId = await _db.ensureCloudRideId(ride.id); final remoteRideId = await _db.ensureCloudRideId(ride.id);
@ -364,6 +357,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
switch (confirmation.status) { switch (confirmation.status) {
case CloudConfirmationStatus.confirmed: case CloudConfirmationStatus.confirmed:
if (ride.cloudOwnerUid == null &&
isAuthenticated &&
providerId == 'firebase') {
await _db.claimRideForCloudOwner(ride.id, user.uid);
}
final syncedItem = SyncItem( final syncedItem = SyncItem(
localRideId: ride.id, localRideId: ride.id,
providerId: providerId, providerId: providerId,
@ -377,6 +375,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
break; break;
case CloudConfirmationStatus.legacyUnverified: case CloudConfirmationStatus.legacyUnverified:
if (ride.cloudOwnerUid == null &&
isAuthenticated &&
providerId == 'firebase') {
await _db.claimRideForCloudOwner(ride.id, user.uid);
}
final legacySyncedItem = SyncItem( final legacySyncedItem = SyncItem(
localRideId: ride.id, localRideId: ride.id,
providerId: providerId, providerId: providerId,
@ -409,6 +412,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
await _outbox.update(authReqItem); await _outbox.update(authReqItem);
recoveredAuthRequired++; recoveredAuthRequired++;
} else { } else {
if (ride.cloudOwnerUid == null &&
isAuthenticated &&
providerId == 'firebase') {
await _db.claimRideForCloudOwner(ride.id, user.uid);
}
final queuedItem = SyncItem( final queuedItem = SyncItem(
localRideId: ride.id, localRideId: ride.id,
providerId: providerId, providerId: providerId,
@ -544,11 +552,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
} }
Future<void> _performProcessQueue() async { Future<void> _performProcessQueue() async {
do { _isProcessing = true;
_needsAnotherPass = false; notifyListeners();
_isProcessing = true; try {
notifyListeners(); do {
try { _needsAnotherPass = false;
final now = _now(); final now = _now();
for (final item in _outbox.getEligibleItems(now)) { for (final item in _outbox.getEligibleItems(now)) {
final provider = _providers[item.providerId]; final provider = _providers[item.providerId];
@ -684,7 +692,10 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
} }
// 4. In-flight cancellation protection: verify item was not cancelled while uploading // 4. In-flight cancellation protection: verify item was not cancelled while uploading
final currentItem = _outbox.getItem(item.localRideId, item.providerId); final currentItem = _outbox.getItem(
item.localRideId,
item.providerId,
);
if (currentItem?.status == SyncStatus.cancelled) { if (currentItem?.status == SyncStatus.cancelled) {
continue; continue;
} }
@ -727,11 +738,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
); );
} }
} }
} finally { } while (_needsAnotherPass);
_isProcessing = false; } finally {
notifyListeners(); _isProcessing = false;
} notifyListeners();
} while (_needsAnotherPass); }
} }
Future<void> cancelSync(int localRideId, String providerId) async { Future<void> cancelSync(int localRideId, String providerId) async {

View File

@ -18,6 +18,8 @@ class _MockAuthService implements AuthService {
AuthUser? _currentUser; AuthUser? _currentUser;
AuthState _authState = const AuthState.unauthenticated(); AuthState _authState = const AuthState.unauthenticated();
String? idTokenToReturn = 'valid_mock_jwt_token'; String? idTokenToReturn = 'valid_mock_jwt_token';
String? refreshedIdToken;
int getIdTokenCalls = 0;
void setAuthenticated(String uid, String email) { void setAuthenticated(String uid, String email) {
_currentUser = AuthUser(uid: uid, email: email, isAnonymous: false); _currentUser = AuthUser(uid: uid, email: email, isAnonymous: false);
@ -45,8 +47,14 @@ class _MockAuthService implements AuthService {
@override @override
bool get isEmailVerified => true; bool get isEmailVerified => true;
@override @override
Future<String?> getIdToken([bool forceRefresh = false]) async => Future<String?> getIdToken([bool forceRefresh = false]) async {
idTokenToReturn; getIdTokenCalls++;
if (forceRefresh && refreshedIdToken != null) {
return refreshedIdToken;
}
return idTokenToReturn;
}
@override @override
Future<void> createUserWithEmailAndPassword( Future<void> createUserWithEmailAndPassword(
String email, String email,
@ -624,7 +632,8 @@ void main() {
jsonEncode({ jsonEncode({
'verified': true, 'verified': true,
'remoteRideId': actualRemoteRideId, 'remoteRideId': actualRemoteRideId,
'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', 'objectKey':
'users/$testUid/rides/$actualRemoteRideId/telemetry.zip',
'size': validSize, 'size': validSize,
'sha256': 'corrupted_hash_mismatch_123', 'sha256': 'corrupted_hash_mismatch_123',
}), }),
@ -657,7 +666,12 @@ void main() {
expect(result.success, isFalse); expect(result.success, isFalse);
expect(result.isRetryable, isFalse); expect(result.isRetryable, isFalse);
expect(result.errorMessage, contains('inconsistent')); expect(result.errorMessage, contains('inconsistent'));
expect(uploadAttempted, isFalse, reason: 'Must NEVER attempt R2 upload when cloud data is inconsistent'); expect(
uploadAttempted,
isFalse,
reason:
'Must NEVER attempt R2 upload when cloud data is inconsistent',
);
}, },
); );
@ -709,7 +723,11 @@ void main() {
expect(result.success, isFalse); expect(result.success, isFalse);
expect(result.isRetryable, isTrue); expect(result.isRetryable, isTrue);
expect(result.errorMessage, contains('temporarily unavailable')); expect(result.errorMessage, contains('temporarily unavailable'));
expect(uploadAttempted, isFalse, reason: 'Must NOT upload bundle when verification is rate limited'); expect(
uploadAttempted,
isFalse,
reason: 'Must NOT upload bundle when verification is rate limited',
);
}, },
); );
@ -728,7 +746,12 @@ void main() {
.doc(testUid) .doc(testUid)
.collection('rides') .collection('rides')
.doc(actualRemoteRideId) .doc(actualRemoteRideId)
.set(createFirestoreRideData(ownerId: 'different_owner_uid', rideId: actualRemoteRideId)); .set(
createFirestoreRideData(
ownerId: 'different_owner_uid',
rideId: actualRemoteRideId,
),
);
var uploadAttempted = false; var uploadAttempted = false;
final mockClient = http_testing.MockClient((request) async { final mockClient = http_testing.MockClient((request) async {
@ -781,7 +804,9 @@ void main() {
}, },
}); });
final mockClient = http_testing.MockClient((request) async => http.Response('{}', 200)); final mockClient = http_testing.MockClient(
(request) async => http.Response('{}', 200),
);
final r2Service = R2UploadService(client: mockClient); final r2Service = R2UploadService(client: mockClient);
final provider = FirebaseSyncProvider( final provider = FirebaseSyncProvider(
db: db, db: db,
@ -820,7 +845,9 @@ void main() {
}, },
}); });
final mockClient = http_testing.MockClient((request) async => http.Response('{}', 200)); final mockClient = http_testing.MockClient(
(request) async => http.Response('{}', 200),
);
final r2Service = R2UploadService(client: mockClient); final r2Service = R2UploadService(client: mockClient);
final provider = FirebaseSyncProvider( final provider = FirebaseSyncProvider(
db: db, db: db,
@ -931,7 +958,8 @@ void main() {
'ownerId': testUid, 'ownerId': testUid,
'telemetryBundle': { 'telemetryBundle': {
'provider': 'cloudflare-r2', 'provider': 'cloudflare-r2',
'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', 'objectKey':
'users/$testUid/rides/$actualRemoteRideId/telemetry.zip',
'status': 'uploaded', 'status': 'uploaded',
}, },
}); });
@ -943,7 +971,8 @@ void main() {
jsonEncode({ jsonEncode({
'verified': true, 'verified': true,
'remoteRideId': actualRemoteRideId, 'remoteRideId': actualRemoteRideId,
'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', 'objectKey':
'users/$testUid/rides/$actualRemoteRideId/telemetry.zip',
'size': 1024, 'size': 1024,
'sha256': 'legacy_hash', 'sha256': 'legacy_hash',
}), }),
@ -974,7 +1003,105 @@ void main() {
expect(result.success, isTrue); expect(result.success, isTrue);
expect(result.remoteId, equals(actualRemoteRideId)); expect(result.remoteId, equals(actualRemoteRideId));
expect(uploadAttempted, isFalse, reason: 'Must not re-upload existing historical bundle'); expect(
uploadAttempted,
isFalse,
reason: 'Must not re-upload existing historical bundle',
);
},
);
test(
'23. Expired Firebase token triggers forceRefresh and confirms on retry',
() async {
await fakeFirestore
.collection('users')
.doc(testUid)
.collection('rides')
.doc(remoteRideId)
.set(createFirestoreRideData());
authService.idTokenToReturn = 'expired_old_token';
authService.refreshedIdToken = 'fresh_valid_jwt_token';
var callCount = 0;
final mockClient = http_testing.MockClient((request) async {
callCount++;
final authHeader = request.headers['Authorization'];
if (authHeader == 'Bearer expired_old_token') {
return http.Response(
jsonEncode({'error': 'Token expired', 'code': 'UNAUTHORIZED'}),
401,
);
}
if (authHeader == 'Bearer fresh_valid_jwt_token') {
return http.Response(
jsonEncode({
'verified': true,
'remoteRideId': remoteRideId,
'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip',
'size': validSize,
'sha256': validSha256,
}),
200,
);
}
return http.Response('{}', 403);
});
final r2Service = R2UploadService(client: mockClient);
final provider = FirebaseSyncProvider(
db: db,
authService: authService,
firestore: fakeFirestore,
r2UploadService: r2Service,
);
final result = await provider.verifyCloudConfirmation(remoteRideId);
expect(result.status, equals(CloudConfirmationStatus.confirmed));
expect(result.isConfirmed, isTrue);
expect(
callCount,
equals(2),
reason: 'Should have retried once with refreshed token',
);
},
);
test(
'24. Worker returns 503 TEMPORARY_ERROR when Firestore temporarily fails (maps to temporarilyUnverifiable)',
() async {
await fakeFirestore
.collection('users')
.doc(testUid)
.collection('rides')
.doc(remoteRideId)
.set(createFirestoreRideData());
final mockClient = http_testing.MockClient((request) async {
return http.Response(
jsonEncode({
'error': 'Firestore temporary lookup error',
'code': 'TEMPORARY_ERROR',
}),
503,
);
});
final r2Service = R2UploadService(client: mockClient);
final provider = FirebaseSyncProvider(
db: db,
authService: authService,
firestore: fakeFirestore,
r2UploadService: r2Service,
);
final result = await provider.verifyCloudConfirmation(remoteRideId);
expect(
result.status,
equals(CloudConfirmationStatus.temporarilyUnverifiable),
);
expect(result.isConfirmed, isFalse);
}, },
); );
}); });

View File

@ -676,8 +676,12 @@ void main() {
await processFuture; await processFuture;
final item = outbox.getItem(rideId, 'firebase'); final item = outbox.getItem(rideId, 'firebase');
expect(item?.status, equals(SyncStatus.cancelled), expect(
reason: 'Item must remain cancelled and not get overwritten with synced'); item?.status,
equals(SyncStatus.cancelled),
reason:
'Item must remain cancelled and not get overwritten with synced',
);
}, },
); );
@ -700,10 +704,7 @@ void main() {
); );
expect(managerWithProviders.activeProviderId, equals('custom_gateway')); expect(managerWithProviders.activeProviderId, equals('custom_gateway'));
final managerNoProviders = SyncManager( final managerNoProviders = SyncManager(outbox, autoStart: false);
outbox,
autoStart: false,
);
expect(managerNoProviders.activeProviderId, equals('fake')); expect(managerNoProviders.activeProviderId, equals('fake'));
}, },
); );
@ -737,7 +738,10 @@ void main() {
final item = await manager.enqueueRide(rideId, autoProcess: false); final item = await manager.enqueueRide(rideId, autoProcess: false);
expect(item.status, equals(SyncStatus.cancelled)); expect(item.status, equals(SyncStatus.cancelled));
expect(item.lastError, contains('belongs to another account')); expect(item.lastError, contains('belongs to another account'));
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.cancelled)); expect(
outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.cancelled),
);
}, },
); );
@ -771,7 +775,10 @@ void main() {
final report1 = await manager.reconcileCompletedRides(); final report1 = await manager.reconcileCompletedRides();
expect(report1.unverifiablePendingCount, equals(1)); expect(report1.unverifiablePendingCount, equals(1));
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.retryWait)); expect(
outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.retryWait),
);
// Step 2: Backend recovers and verifies the bundle in R2 // Step 2: Backend recovers and verifies the bundle in R2
fakeProvider.confirmationResults[remoteRideId] = fakeProvider.confirmationResults[remoteRideId] =
@ -785,8 +792,12 @@ void main() {
outbox.getItem(rideId, 'firebase')?.status, outbox.getItem(rideId, 'firebase')?.status,
equals(SyncStatus.synced), equals(SyncStatus.synced),
); );
expect(fakeProvider.uploadedItems.isEmpty, isTrue, expect(
reason: 'Must confirm and mark synced without re-uploading the bundle'); fakeProvider.uploadedItems.isEmpty,
isTrue,
reason:
'Must confirm and mark synced without re-uploading the bundle',
);
}, },
); );
@ -826,8 +837,53 @@ void main() {
final item = outbox.getItem(rideId, 'firebase'); final item = outbox.getItem(rideId, 'firebase');
expect(item?.status, equals(SyncStatus.synced)); expect(item?.status, equals(SyncStatus.synced));
expect(item?.lastError, contains('without modern checksum')); expect(item?.lastError, contains('without modern checksum'));
expect(fakeProvider.uploadedItems.isEmpty, isTrue, expect(
reason: 'Must never re-upload existing legacy unverified bundle'); fakeProvider.uploadedItems.isEmpty,
isTrue,
reason: 'Must never re-upload existing legacy unverified bundle',
);
},
);
test(
'18. Reconciliation Never Claims Unauthorized Rides: cloudOwnerUid remains null when verification returns unauthorized',
() async {
authService.setAuthenticatedUser('rider_eve', 'eve@example.com');
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 15, 0, 0),
);
await db.endRide(rideId, const RidesCompanion());
final remoteRideId = await db.ensureCloudRideId(rideId);
// Verification returns unauthorized (foreign-owned or permission denied)
fakeProvider.confirmationResults[remoteRideId] =
CloudConfirmationResult.unauthorized(
remoteRideId: remoteRideId,
message: 'Foreign owner in cloud metadata',
);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
final report = await manager.reconcileCompletedRides();
// Must NOT claim ownership for rider_eve
final localOwner = await db.getCloudOwnerUid(rideId);
expect(
localOwner,
isNull,
reason: 'Must NEVER claim an unauthorized ride',
);
expect(report.foreignOwnerSkippedCount, equals(1));
expect(report.errors.isNotEmpty, isTrue);
}, },
); );
}); });