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:
parent
cff4734678
commit
de6d575cee
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue