fix(sync): harden R2 cloud confirmation verification, add worker tests, and resolve CI backup gate
- Enforce positive bundle size, matching SHA-256, user-scoped object keys, and legacyUnverified status for historical bundles in R2UploadService and FirebaseSyncProvider - Update SyncManager reconciliation to accept legacyUnverified status, mark historical rides as synced without re-upload, and track legacy unverified counts - Add 18-test vitest suite for Cloudflare Worker /r2/verify endpoint covering CORS, auth, validation, ownership, HEAD/GET, legacy bundles, and errors - Harden test/disposable_backup_recovery_verification_test.dart with candidate path discovery and CI skip guard
This commit is contained in:
parent
5e0db4c191
commit
cff4734678
File diff suppressed because it is too large
Load Diff
|
|
@ -4,6 +4,7 @@
|
||||||
"description": "Cloudflare Worker gateway for MotorSignal R2 telemetry bundle storage",
|
"description": "Cloudflare Worker gateway for MotorSignal R2 telemetry bundle storage",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
"dev": "wrangler dev",
|
"dev": "wrangler dev",
|
||||||
"deploy": "wrangler deploy"
|
"deploy": "wrangler deploy"
|
||||||
},
|
},
|
||||||
|
|
@ -13,6 +14,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20240806.0",
|
"@cloudflare/workers-types": "^4.20240806.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
|
"vitest": "^2.1.8",
|
||||||
"wrangler": "^3.70.0"
|
"wrangler": "^3.70.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,340 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock jose before importing worker
|
||||||
|
vi.mock('jose', () => ({
|
||||||
|
createRemoteJWKSet: vi.fn(() => vi.fn()),
|
||||||
|
jwtVerify: vi.fn(async (token: string) => {
|
||||||
|
if (token === 'valid_token') {
|
||||||
|
return {
|
||||||
|
payload: {
|
||||||
|
sub: 'test_user_123',
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
firebase: { sign_in_provider: 'password' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (token === 'anon_token') {
|
||||||
|
return {
|
||||||
|
payload: {
|
||||||
|
sub: 'anon_user_456',
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
firebase: { sign_in_provider: 'anonymous' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import worker, { type Env } from './index';
|
||||||
|
|
||||||
|
describe('Worker /r2/verify endpoint', () => {
|
||||||
|
let mockEnv: Env;
|
||||||
|
let mockR2Head: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
mockR2Head = vi.fn();
|
||||||
|
mockEnv = {
|
||||||
|
R2_BUCKET: {
|
||||||
|
head: mockR2Head,
|
||||||
|
} as any,
|
||||||
|
FIREBASE_PROJECT_ID: 'motoapp-test',
|
||||||
|
ENVIRONMENT: 'test',
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_EMAIL: 'sa@test.iam.gserviceaccount.com',
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: 'test-key',
|
||||||
|
GOOGLE_PLAY_PACKAGE_NAME: 'app.motorsignal.motorcycle',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockValidFirestore(uid = 'test_user_123', rideId = 'ride_abc_123') {
|
||||||
|
mockFirestore(200, {
|
||||||
|
fields: {
|
||||||
|
ownerId: { stringValue: uid },
|
||||||
|
id: { stringValue: rideId },
|
||||||
|
telemetryBundlePath: {
|
||||||
|
stringValue: `users/${uid}/rides/${rideId}/telemetry.zip`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFirestore(status: number, data?: any) {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||||
|
if (url.includes('firestore.googleapis.com')) {
|
||||||
|
return new Response(JSON.stringify(data || {}), { status });
|
||||||
|
}
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('1. returns CORS headers on OPTIONS preflight request', async () => {
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
|
||||||
|
method: 'OPTIONS',
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2. returns 404 Endpoint not found for unknown paths', async () => {
|
||||||
|
const req = new Request('https://worker.local/unknown-path', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.error).toBe('Endpoint not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3. rejects /r2/verify with 401 when Authorization header is missing', async () => {
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.error).toContain('Missing or malformed Authorization header');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4. rejects /r2/verify with 401 when Authorization header lacks Bearer prefix', async () => {
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: 'Basic dXNlcjpwYXNz' },
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5. rejects /r2/verify with 401 when Firebase token verification fails', async () => {
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: 'Bearer invalid_or_expired_token' },
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.error).toContain('Invalid, expired, or unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('6. rejects /r2/verify with 400 when rideId query parameter is missing', async () => {
|
||||||
|
const req = new Request('https://worker.local/r2/verify', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.error).toContain('Invalid rideId parameter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('7. rejects /r2/verify with 400 when rideId contains invalid characters or path traversal', async () => {
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=../bad/traversal', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.error).toContain('Invalid rideId parameter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('8. returns 403 UNAUTHORIZED when Firestore returns 403', async () => {
|
||||||
|
mockFirestore(403);
|
||||||
|
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(403);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('UNAUTHORIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('9. returns 404 NOT_FOUND when Firestore ride document does not exist', async () => {
|
||||||
|
mockFirestore(404);
|
||||||
|
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(404);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('10. returns 409 INVALID_METADATA when Firestore document has invalid or missing bundle path', async () => {
|
||||||
|
mockFirestore(200, {
|
||||||
|
fields: {
|
||||||
|
ownerId: { stringValue: 'test_user_123' },
|
||||||
|
id: { stringValue: 'ride_abc_123' },
|
||||||
|
telemetryBundlePath: { stringValue: 'invalid/bundle/path.zip' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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(409);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('INVALID_METADATA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('11. returns 404 OBJECT_NOT_FOUND when bundle does not exist in R2', async () => {
|
||||||
|
mockValidFirestore();
|
||||||
|
mockR2Head.mockResolvedValue(null);
|
||||||
|
|
||||||
|
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(404);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('OBJECT_NOT_FOUND');
|
||||||
|
expect(mockR2Head).toHaveBeenCalledWith('users/test_user_123/rides/ride_abc_123/telemetry.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('12. returns 200 with verification details on GET when object exists in R2', async () => {
|
||||||
|
mockValidFirestore();
|
||||||
|
mockR2Head.mockResolvedValue({
|
||||||
|
size: 4096,
|
||||||
|
httpEtag: '"etag-12345"',
|
||||||
|
customMetadata: {
|
||||||
|
sha256: 'abc123def456',
|
||||||
|
appVersion: '1.1.0',
|
||||||
|
uploadedAt: '2026-10-12T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.verified).toBe(true);
|
||||||
|
expect(body.remoteRideId).toBe('ride_abc_123');
|
||||||
|
expect(body.objectKey).toBe('users/test_user_123/rides/ride_abc_123/telemetry.zip');
|
||||||
|
expect(body.size).toBe(4096);
|
||||||
|
expect(body.etag).toBe('"etag-12345"');
|
||||||
|
expect(body.sha256).toBe('abc123def456');
|
||||||
|
expect(body.appVersion).toBe('1.1.0');
|
||||||
|
expect(body.uploadedAt).toBe('2026-10-12T10:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('13. sets verification properties in JSON body on GET and headers on HEAD', async () => {
|
||||||
|
mockValidFirestore();
|
||||||
|
mockR2Head.mockResolvedValue({
|
||||||
|
size: 8192,
|
||||||
|
httpEtag: '"etag-8192"',
|
||||||
|
customMetadata: { sha256: 'sha_test_789' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getReq = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
const getRes = await worker.fetch(getReq, mockEnv);
|
||||||
|
const getBody = await getRes.json() as any;
|
||||||
|
expect(getBody.etag).toBe('"etag-8192"');
|
||||||
|
expect(getBody.remoteRideId).toBe('ride_abc_123');
|
||||||
|
expect(getBody.sha256).toBe('sha_test_789');
|
||||||
|
expect(getBody.size).toBe(8192);
|
||||||
|
|
||||||
|
const headReq = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||||
|
method: 'HEAD',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
const headRes = await worker.fetch(headReq, mockEnv);
|
||||||
|
expect(headRes.headers.get('etag')).toBe('"etag-8192"');
|
||||||
|
expect(headRes.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123');
|
||||||
|
expect(headRes.headers.get('X-MotorSignal-Sha256')).toBe('sha_test_789');
|
||||||
|
expect(headRes.headers.get('X-MotorSignal-Size')).toBe('8192');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('14. handles HEAD request on /r2/verify with 200 and headers but null body', async () => {
|
||||||
|
mockValidFirestore();
|
||||||
|
mockR2Head.mockResolvedValue({
|
||||||
|
size: 4096,
|
||||||
|
httpEtag: '"etag-head"',
|
||||||
|
customMetadata: { sha256: 'sha_head' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||||
|
method: 'HEAD',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toBeNull();
|
||||||
|
expect(res.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123');
|
||||||
|
expect(res.headers.get('X-MotorSignal-Size')).toBe('4096');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('15. handles HEAD request on /r2/verify when bundle not found in R2 with 404', async () => {
|
||||||
|
mockValidFirestore();
|
||||||
|
mockR2Head.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||||
|
method: 'HEAD',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('OBJECT_NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('16. correctly returns undefined sha256 for historical bundles without checksum metadata', async () => {
|
||||||
|
mockValidFirestore('test_user_123', 'ride_legacy_001');
|
||||||
|
mockR2Head.mockResolvedValue({
|
||||||
|
size: 1024,
|
||||||
|
httpEtag: '"etag-legacy"',
|
||||||
|
customMetadata: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=ride_legacy_001', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.verified).toBe(true);
|
||||||
|
expect(body.sha256).toBeUndefined();
|
||||||
|
expect(res.headers.has('X-MotorSignal-Sha256')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('17. targets user-scoped R2 object key matching authenticated user', async () => {
|
||||||
|
mockValidFirestore('test_user_123', 'ride_custom_id');
|
||||||
|
mockR2Head.mockResolvedValue({
|
||||||
|
size: 512,
|
||||||
|
httpEtag: '"etag-scoped"',
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=ride_custom_id', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
await worker.fetch(req, mockEnv);
|
||||||
|
expect(mockR2Head).toHaveBeenCalledWith('users/test_user_123/rides/ride_custom_id/telemetry.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('18. returns 500 when unexpected internal worker exception occurs', async () => {
|
||||||
|
mockValidFirestore('test_user_123', 'ride_err_1');
|
||||||
|
mockR2Head.mockRejectedValue(new Error('R2 cluster unreachable'));
|
||||||
|
|
||||||
|
const req = new Request('https://worker.local/r2/verify?rideId=ride_err_1', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: 'Bearer valid_token' },
|
||||||
|
});
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.error).toBe('Internal Worker Exception');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -51,7 +51,7 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
||||||
final result = await verifyCloudConfirmation(remoteRideId);
|
final result = await verifyCloudConfirmation(remoteRideId);
|
||||||
return result.isConfirmed;
|
return result.isConfirmed || result.isLegacyUnverified;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -169,10 +169,33 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final syncVersion = (data['syncVersion'] as num?)?.toInt() ?? 1;
|
||||||
final expectedSha256 = (bundle['sha256'] ?? data['sha256'])?.toString();
|
final expectedSha256 = (bundle['sha256'] ?? data['sha256'])?.toString();
|
||||||
final expectedSize =
|
final expectedSize =
|
||||||
(bundle['bundleSizeBytes'] ?? data['bundleSizeBytes']) as num?;
|
(bundle['bundleSizeBytes'] ?? data['bundleSizeBytes']) as num?;
|
||||||
|
|
||||||
|
// For modern bundles (syncVersion >= 3), require positive size and non-empty sha256
|
||||||
|
if (syncVersion >= 3) {
|
||||||
|
if (expectedSha256 == null || expectedSha256.trim().isEmpty) {
|
||||||
|
return CloudConfirmationResult.inconsistent(
|
||||||
|
remoteRideId: remoteRideId,
|
||||||
|
ownerUid: user.uid,
|
||||||
|
objectKey: expectedObjectKey,
|
||||||
|
message:
|
||||||
|
'Modern bundle (syncVersion >= 3) missing required sha256 metadata.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (expectedSize == null || expectedSize <= 0) {
|
||||||
|
return CloudConfirmationResult.inconsistent(
|
||||||
|
remoteRideId: remoteRideId,
|
||||||
|
ownerUid: user.uid,
|
||||||
|
objectKey: expectedObjectKey,
|
||||||
|
message:
|
||||||
|
'Modern bundle (syncVersion >= 3) has invalid bundleSizeBytes ($expectedSize).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: R2 bundle verification via authenticated lightweight Worker check
|
// Step 2: R2 bundle verification via authenticated lightweight Worker check
|
||||||
var idToken = await _authService.getIdToken();
|
var idToken = await _authService.getIdToken();
|
||||||
if (idToken == null || idToken.isEmpty) {
|
if (idToken == null || idToken.isEmpty) {
|
||||||
|
|
@ -204,6 +227,18 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
message: 'Firestore metadata and R2 bundle verified.',
|
message: 'Firestore metadata and R2 bundle verified.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case CloudConfirmationStatus.legacyUnverified:
|
||||||
|
return CloudConfirmationResult.legacyUnverified(
|
||||||
|
remoteRideId: remoteRideId,
|
||||||
|
ownerUid: user.uid,
|
||||||
|
objectKey: r2Result.metadata?.objectKey ?? expectedObjectKey,
|
||||||
|
sizeBytes: r2Result.metadata?.size ?? expectedSize?.toInt(),
|
||||||
|
sha256: r2Result.metadata?.sha256 ?? expectedSha256,
|
||||||
|
message:
|
||||||
|
r2Result.errorMessage ??
|
||||||
|
'Historical bundle verified in R2 without full checksum metadata.',
|
||||||
|
);
|
||||||
|
|
||||||
case CloudConfirmationStatus.missing:
|
case CloudConfirmationStatus.missing:
|
||||||
return CloudConfirmationResult.missing(
|
return CloudConfirmationResult.missing(
|
||||||
remoteRideId: remoteRideId,
|
remoteRideId: remoteRideId,
|
||||||
|
|
@ -289,7 +324,7 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
// derive the remote identity from the local auto-increment database ID.
|
// derive the remote identity from the local auto-increment database ID.
|
||||||
final remoteRideId = await _db.ensureCloudRideId(item.localRideId);
|
final remoteRideId = await _db.ensureCloudRideId(item.localRideId);
|
||||||
final confirmation = await verifyCloudConfirmation(remoteRideId);
|
final confirmation = await verifyCloudConfirmation(remoteRideId);
|
||||||
if (confirmation.isConfirmed) {
|
if (confirmation.isConfirmed || confirmation.isLegacyUnverified) {
|
||||||
return SyncResult.success(remoteId: remoteRideId);
|
return SyncResult.success(remoteId: remoteRideId);
|
||||||
}
|
}
|
||||||
if (confirmation.isInconsistent) {
|
if (confirmation.isInconsistent) {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,17 @@ class R2VerificationResponse {
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const R2VerificationResponse.legacyUnverified(
|
||||||
|
R2VerificationMetadata metadata, {
|
||||||
|
int statusCode = 200,
|
||||||
|
String? message,
|
||||||
|
}) : this(
|
||||||
|
status: CloudConfirmationStatus.legacyUnverified,
|
||||||
|
statusCode: statusCode,
|
||||||
|
metadata: metadata,
|
||||||
|
errorMessage: message,
|
||||||
|
);
|
||||||
|
|
||||||
const R2VerificationResponse.missing(String message, {int statusCode = 404})
|
const R2VerificationResponse.missing(String message, {int statusCode = 404})
|
||||||
: this(
|
: this(
|
||||||
status: CloudConfirmationStatus.missing,
|
status: CloudConfirmationStatus.missing,
|
||||||
|
|
@ -298,26 +309,45 @@ class R2UploadService {
|
||||||
}
|
}
|
||||||
final data = Map<String, dynamic>.from(decoded);
|
final data = Map<String, dynamic>.from(decoded);
|
||||||
final respRideId = data['remoteRideId']?.toString();
|
final respRideId = data['remoteRideId']?.toString();
|
||||||
if (respRideId != null && respRideId != remoteRideId) {
|
if (respRideId == null || respRideId != remoteRideId) {
|
||||||
return R2VerificationResponse.inconsistent(
|
return R2VerificationResponse.inconsistent(
|
||||||
'Worker verification rideId ($respRideId) does not match expected ($remoteRideId).',
|
'Worker verification rideId ($respRideId) does not match expected ($remoteRideId).',
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final objectKey = data['objectKey']?.toString();
|
final objectKey = data['objectKey']?.toString();
|
||||||
if (objectKey != null &&
|
if (objectKey == null ||
|
||||||
(!objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') ||
|
!objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') ||
|
||||||
!objectKey.startsWith('users/'))) {
|
!objectKey.startsWith('users/')) {
|
||||||
return R2VerificationResponse.inconsistent(
|
return R2VerificationResponse.inconsistent(
|
||||||
'Worker verification objectKey ($objectKey) does not match expected pattern.',
|
'Worker verification objectKey ($objectKey) does not match expected pattern.',
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final respSize = (data['size'] as num?)?.toInt();
|
||||||
|
if (respSize == null || respSize <= 0) {
|
||||||
|
return R2VerificationResponse.inconsistent(
|
||||||
|
'Worker verification returned invalid or non-positive object size ($respSize).',
|
||||||
|
statusCode: status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (expectedSize != null &&
|
||||||
|
expectedSize > 0 &&
|
||||||
|
expectedSize != respSize) {
|
||||||
|
return R2VerificationResponse.inconsistent(
|
||||||
|
'Size mismatch: expected $expectedSize bytes but got $respSize bytes.',
|
||||||
|
statusCode: 422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final respSha256 = data['sha256']?.toString();
|
final respSha256 = data['sha256']?.toString();
|
||||||
if (expectedSha256 != null &&
|
if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) {
|
||||||
expectedSha256.trim().isNotEmpty &&
|
if (respSha256 == null || respSha256.trim().isEmpty) {
|
||||||
respSha256 != null &&
|
return R2VerificationResponse.inconsistent(
|
||||||
respSha256.trim().isNotEmpty) {
|
'Missing required SHA-256 in R2 worker response for ride with expected hash $expectedSha256.',
|
||||||
|
statusCode: status,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (expectedSha256.trim().toLowerCase() !=
|
if (expectedSha256.trim().toLowerCase() !=
|
||||||
respSha256.trim().toLowerCase()) {
|
respSha256.trim().toLowerCase()) {
|
||||||
return R2VerificationResponse.inconsistent(
|
return R2VerificationResponse.inconsistent(
|
||||||
|
|
@ -326,29 +356,30 @@ class R2UploadService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final respSize = (data['size'] as num?)?.toInt();
|
|
||||||
if (expectedSize != null &&
|
final metadata = R2VerificationMetadata(
|
||||||
expectedSize > 0 &&
|
verified: true,
|
||||||
respSize != null &&
|
remoteRideId: remoteRideId,
|
||||||
respSize > 0) {
|
objectKey: objectKey,
|
||||||
if (expectedSize != respSize) {
|
size: respSize,
|
||||||
return R2VerificationResponse.inconsistent(
|
sha256: respSha256,
|
||||||
'Size mismatch: expected $expectedSize bytes but got $respSize bytes.',
|
etag: data['etag']?.toString(),
|
||||||
statusCode: 422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return R2VerificationResponse.confirmed(
|
|
||||||
R2VerificationMetadata(
|
|
||||||
verified: true,
|
|
||||||
remoteRideId: remoteRideId,
|
|
||||||
objectKey: objectKey,
|
|
||||||
size: respSize,
|
|
||||||
sha256: respSha256,
|
|
||||||
etag: data['etag']?.toString(),
|
|
||||||
),
|
|
||||||
statusCode: status,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If bundle lacks modern checksum metadata, report as legacyUnverified rather than confirmed.
|
||||||
|
if (expectedSha256 == null ||
|
||||||
|
expectedSha256.trim().isEmpty ||
|
||||||
|
respSha256 == null ||
|
||||||
|
respSha256.trim().isEmpty) {
|
||||||
|
return R2VerificationResponse.legacyUnverified(
|
||||||
|
metadata,
|
||||||
|
statusCode: status,
|
||||||
|
message:
|
||||||
|
'Historical bundle verified in R2 without full checksum metadata.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return R2VerificationResponse.confirmed(metadata, statusCode: status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return R2VerificationResponse.temporarilyUnverifiable(
|
return R2VerificationResponse.temporarilyUnverifiable(
|
||||||
'Failed to parse worker verification response: $e',
|
'Failed to parse worker verification response: $e',
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class CompletedRideReconciliationReport {
|
||||||
final int recoveredQueuedCount;
|
final int recoveredQueuedCount;
|
||||||
final int recoveredAuthRequiredCount;
|
final int recoveredAuthRequiredCount;
|
||||||
final int recoveredAlreadySyncedCount;
|
final int recoveredAlreadySyncedCount;
|
||||||
|
final int recoveredLegacyUnverifiedCount;
|
||||||
final int foreignOwnerSkippedCount;
|
final int foreignOwnerSkippedCount;
|
||||||
final int inconsistentSkippedCount;
|
final int inconsistentSkippedCount;
|
||||||
final int unverifiablePendingCount;
|
final int unverifiablePendingCount;
|
||||||
|
|
@ -25,6 +26,7 @@ class CompletedRideReconciliationReport {
|
||||||
this.recoveredQueuedCount = 0,
|
this.recoveredQueuedCount = 0,
|
||||||
this.recoveredAuthRequiredCount = 0,
|
this.recoveredAuthRequiredCount = 0,
|
||||||
this.recoveredAlreadySyncedCount = 0,
|
this.recoveredAlreadySyncedCount = 0,
|
||||||
|
this.recoveredLegacyUnverifiedCount = 0,
|
||||||
this.foreignOwnerSkippedCount = 0,
|
this.foreignOwnerSkippedCount = 0,
|
||||||
this.inconsistentSkippedCount = 0,
|
this.inconsistentSkippedCount = 0,
|
||||||
this.unverifiablePendingCount = 0,
|
this.unverifiablePendingCount = 0,
|
||||||
|
|
@ -42,6 +44,7 @@ class CompletedRideReconciliationReport {
|
||||||
'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, '
|
'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, '
|
||||||
'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, '
|
'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, '
|
||||||
'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, '
|
'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, '
|
||||||
|
'legacyUnverified: $recoveredLegacyUnverifiedCount, '
|
||||||
'foreignSkipped: $foreignOwnerSkippedCount, inconsistent: $inconsistentSkippedCount, '
|
'foreignSkipped: $foreignOwnerSkippedCount, inconsistent: $inconsistentSkippedCount, '
|
||||||
'unverifiable: $unverifiablePendingCount, errors: $errorCount)';
|
'unverifiable: $unverifiablePendingCount, errors: $errorCount)';
|
||||||
}
|
}
|
||||||
|
|
@ -292,6 +295,7 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
int recoveredQueued = 0;
|
int recoveredQueued = 0;
|
||||||
int recoveredAuthRequired = 0;
|
int recoveredAuthRequired = 0;
|
||||||
int recoveredAlreadySynced = 0;
|
int recoveredAlreadySynced = 0;
|
||||||
|
int recoveredLegacyUnverified = 0;
|
||||||
int foreignOwnerSkipped = 0;
|
int foreignOwnerSkipped = 0;
|
||||||
int inconsistentSkipped = 0;
|
int inconsistentSkipped = 0;
|
||||||
int unverifiablePending = 0;
|
int unverifiablePending = 0;
|
||||||
|
|
@ -372,6 +376,22 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
recoveredAlreadySynced++;
|
recoveredAlreadySynced++;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case CloudConfirmationStatus.legacyUnverified:
|
||||||
|
final legacySyncedItem = SyncItem(
|
||||||
|
localRideId: ride.id,
|
||||||
|
providerId: providerId,
|
||||||
|
status: SyncStatus.synced,
|
||||||
|
remoteId: remoteRideId,
|
||||||
|
createdAt: ride.endTime ?? ride.startTime,
|
||||||
|
uploadedAt: _now(),
|
||||||
|
lastError:
|
||||||
|
'Legacy ride cloud-confirmed without modern checksum',
|
||||||
|
);
|
||||||
|
await _outbox.update(legacySyncedItem);
|
||||||
|
recoveredLegacyUnverified++;
|
||||||
|
recoveredAlreadySynced++;
|
||||||
|
break;
|
||||||
|
|
||||||
case CloudConfirmationStatus.missing:
|
case CloudConfirmationStatus.missing:
|
||||||
final providerRequiresAuth =
|
final providerRequiresAuth =
|
||||||
provider != null && await provider.requiresAuthentication();
|
provider != null && await provider.requiresAuthentication();
|
||||||
|
|
@ -471,6 +491,7 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
recoveredQueuedCount: recoveredQueued,
|
recoveredQueuedCount: recoveredQueued,
|
||||||
recoveredAuthRequiredCount: recoveredAuthRequired,
|
recoveredAuthRequiredCount: recoveredAuthRequired,
|
||||||
recoveredAlreadySyncedCount: recoveredAlreadySynced,
|
recoveredAlreadySyncedCount: recoveredAlreadySynced,
|
||||||
|
recoveredLegacyUnverifiedCount: recoveredLegacyUnverified,
|
||||||
foreignOwnerSkippedCount: foreignOwnerSkipped,
|
foreignOwnerSkippedCount: foreignOwnerSkipped,
|
||||||
inconsistentSkippedCount: inconsistentSkipped,
|
inconsistentSkippedCount: inconsistentSkipped,
|
||||||
unverifiablePendingCount: unverifiablePending,
|
unverifiablePendingCount: unverifiablePending,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ enum CloudConfirmationStatus {
|
||||||
|
|
||||||
/// Network outage, timeout, rate limit, or transient backend failure.
|
/// Network outage, timeout, rate limit, or transient backend failure.
|
||||||
temporarilyUnverifiable,
|
temporarilyUnverifiable,
|
||||||
|
|
||||||
|
/// Bundle exists in cloud (e.g. historical ride) but lacks modern cryptographic checksum verification.
|
||||||
|
legacyUnverified,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicit verification report for a cloud ride.
|
/// Explicit verification report for a cloud ride.
|
||||||
|
|
@ -98,7 +101,26 @@ class CloudConfirmationResult {
|
||||||
message: message,
|
message: message,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const CloudConfirmationResult.legacyUnverified({
|
||||||
|
required String remoteRideId,
|
||||||
|
String? ownerUid,
|
||||||
|
String? objectKey,
|
||||||
|
int? sizeBytes,
|
||||||
|
String? sha256,
|
||||||
|
String? message,
|
||||||
|
}) : this(
|
||||||
|
status: CloudConfirmationStatus.legacyUnverified,
|
||||||
|
remoteRideId: remoteRideId,
|
||||||
|
ownerUid: ownerUid,
|
||||||
|
objectKey: objectKey,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
sha256: sha256,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
|
|
||||||
bool get isConfirmed => status == CloudConfirmationStatus.confirmed;
|
bool get isConfirmed => status == CloudConfirmationStatus.confirmed;
|
||||||
|
bool get isLegacyUnverified =>
|
||||||
|
status == CloudConfirmationStatus.legacyUnverified;
|
||||||
bool get isMissing => status == CloudConfirmationStatus.missing;
|
bool get isMissing => status == CloudConfirmationStatus.missing;
|
||||||
bool get isInconsistent => status == CloudConfirmationStatus.inconsistent;
|
bool get isInconsistent => status == CloudConfirmationStatus.inconsistent;
|
||||||
bool get isUnauthorized => status == CloudConfirmationStatus.unauthorized;
|
bool get isUnauthorized => status == CloudConfirmationStatus.unauthorized;
|
||||||
|
|
@ -130,7 +152,7 @@ abstract class SyncProvider {
|
||||||
/// Backward compatible wrapper around [verifyCloudConfirmation].
|
/// Backward compatible wrapper around [verifyCloudConfirmation].
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
||||||
final result = await verifyCloudConfirmation(remoteRideId);
|
final result = await verifyCloudConfirmation(remoteRideId);
|
||||||
return result.isConfirmed;
|
return result.isConfirmed || result.isLegacyUnverified;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deeply verifies whether [remoteRideId] is confirmed and recoverable in the cloud.
|
/// Deeply verifies whether [remoteRideId] is confirmed and recoverable in the cloud.
|
||||||
|
|
@ -181,7 +203,7 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
||||||
final result = await verifyCloudConfirmation(remoteRideId);
|
final result = await verifyCloudConfirmation(remoteRideId);
|
||||||
return result.isConfirmed;
|
return result.isConfirmed || result.isLegacyUnverified;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -225,7 +247,7 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
final existingRemoteId = item.remoteId;
|
final existingRemoteId = item.remoteId;
|
||||||
if (existingRemoteId != null) {
|
if (existingRemoteId != null) {
|
||||||
final confirmation = await verifyCloudConfirmation(existingRemoteId);
|
final confirmation = await verifyCloudConfirmation(existingRemoteId);
|
||||||
if (confirmation.isConfirmed) {
|
if (confirmation.isConfirmed || confirmation.isLegacyUnverified) {
|
||||||
return SyncResult.success(remoteId: existingRemoteId);
|
return SyncResult.success(remoteId: existingRemoteId);
|
||||||
}
|
}
|
||||||
if (confirmation.isInconsistent) {
|
if (confirmation.isInconsistent) {
|
||||||
|
|
|
||||||
|
|
@ -560,7 +560,8 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await provider.verifyCloudConfirmation(remoteRideId);
|
final result = await provider.verifyCloudConfirmation(remoteRideId);
|
||||||
expect(result.status, equals(CloudConfirmationStatus.confirmed));
|
expect(result.status, equals(CloudConfirmationStatus.legacyUnverified));
|
||||||
|
expect(result.isLegacyUnverified, isTrue);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -758,5 +759,223 @@ void main() {
|
||||||
expect(uploadAttempted, isFalse);
|
expect(uploadAttempted, isFalse);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'18. Modern bundle (syncVersion >= 3) missing required sha256 returns inconsistent',
|
||||||
|
() async {
|
||||||
|
await fakeFirestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(testUid)
|
||||||
|
.collection('rides')
|
||||||
|
.doc(remoteRideId)
|
||||||
|
.set({
|
||||||
|
'id': remoteRideId,
|
||||||
|
'ownerId': testUid,
|
||||||
|
'syncVersion': 3,
|
||||||
|
'bundleSizeBytes': 2048,
|
||||||
|
'telemetryBundle': {
|
||||||
|
'provider': 'cloudflare-r2',
|
||||||
|
'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip',
|
||||||
|
'status': 'uploaded',
|
||||||
|
'bundleSizeBytes': 2048,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
final mockClient = http_testing.MockClient((request) async => http.Response('{}', 200));
|
||||||
|
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.inconsistent));
|
||||||
|
expect(result.isInconsistent, isTrue);
|
||||||
|
expect(result.message, contains('missing required sha256'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'19. Modern bundle (syncVersion >= 3) with non-positive bundleSizeBytes returns inconsistent',
|
||||||
|
() async {
|
||||||
|
await fakeFirestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(testUid)
|
||||||
|
.collection('rides')
|
||||||
|
.doc(remoteRideId)
|
||||||
|
.set({
|
||||||
|
'id': remoteRideId,
|
||||||
|
'ownerId': testUid,
|
||||||
|
'syncVersion': 3,
|
||||||
|
'bundleSizeBytes': 0,
|
||||||
|
'sha256': validSha256,
|
||||||
|
'telemetryBundle': {
|
||||||
|
'provider': 'cloudflare-r2',
|
||||||
|
'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip',
|
||||||
|
'status': 'uploaded',
|
||||||
|
'bundleSizeBytes': 0,
|
||||||
|
'sha256': validSha256,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
final mockClient = http_testing.MockClient((request) async => http.Response('{}', 200));
|
||||||
|
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.inconsistent));
|
||||||
|
expect(result.isInconsistent, isTrue);
|
||||||
|
expect(result.message, contains('invalid bundleSizeBytes'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'20. R2 Worker response with invalid objectKey pattern returns inconsistent',
|
||||||
|
() async {
|
||||||
|
await fakeFirestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(testUid)
|
||||||
|
.collection('rides')
|
||||||
|
.doc(remoteRideId)
|
||||||
|
.set(createFirestoreRideData());
|
||||||
|
|
||||||
|
final mockClient = http_testing.MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'verified': true,
|
||||||
|
'remoteRideId': remoteRideId,
|
||||||
|
'objectKey': 'wrong_path/telemetry.zip',
|
||||||
|
'size': validSize,
|
||||||
|
'sha256': validSha256,
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.inconsistent));
|
||||||
|
expect(result.message, contains('objectKey'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'21. R2 Worker response with non-positive size returns inconsistent',
|
||||||
|
() async {
|
||||||
|
await fakeFirestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(testUid)
|
||||||
|
.collection('rides')
|
||||||
|
.doc(remoteRideId)
|
||||||
|
.set(createFirestoreRideData());
|
||||||
|
|
||||||
|
final mockClient = http_testing.MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'verified': true,
|
||||||
|
'remoteRideId': remoteRideId,
|
||||||
|
'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip',
|
||||||
|
'size': 0,
|
||||||
|
'sha256': validSha256,
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.inconsistent));
|
||||||
|
expect(result.message, contains('size'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'22. uploadRide Protection: Legacy unverified ride confirms without re-uploading bundle',
|
||||||
|
() async {
|
||||||
|
final rideId = await db.startRide(
|
||||||
|
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
|
||||||
|
);
|
||||||
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
await db.claimRideForCloudOwner(rideId, testUid);
|
||||||
|
final actualRemoteRideId = await db.ensureCloudRideId(rideId);
|
||||||
|
|
||||||
|
// Legacy Firestore record without sha256
|
||||||
|
await fakeFirestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(testUid)
|
||||||
|
.collection('rides')
|
||||||
|
.doc(actualRemoteRideId)
|
||||||
|
.set({
|
||||||
|
'id': actualRemoteRideId,
|
||||||
|
'ownerId': testUid,
|
||||||
|
'telemetryBundle': {
|
||||||
|
'provider': 'cloudflare-r2',
|
||||||
|
'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip',
|
||||||
|
'status': 'uploaded',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var uploadAttempted = false;
|
||||||
|
final mockClient = http_testing.MockClient((request) async {
|
||||||
|
if (request.url.path == '/r2/verify') {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'verified': true,
|
||||||
|
'remoteRideId': actualRemoteRideId,
|
||||||
|
'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip',
|
||||||
|
'size': 1024,
|
||||||
|
'sha256': 'legacy_hash',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.url.path == '/r2/upload') {
|
||||||
|
uploadAttempted = true;
|
||||||
|
}
|
||||||
|
return http.Response('{}', 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final r2Service = R2UploadService(client: mockClient);
|
||||||
|
final provider = FirebaseSyncProvider(
|
||||||
|
db: db,
|
||||||
|
authService: authService,
|
||||||
|
firestore: fakeFirestore,
|
||||||
|
r2UploadService: r2Service,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await provider.uploadRide(
|
||||||
|
SyncItem(
|
||||||
|
localRideId: rideId,
|
||||||
|
providerId: 'firebase',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.remoteId, equals(actualRemoteRideId));
|
||||||
|
expect(uploadAttempted, isFalse, reason: 'Must not re-upload existing historical bundle');
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -789,5 +789,46 @@ void main() {
|
||||||
reason: 'Must confirm and mark synced without re-uploading the bundle');
|
reason: 'Must confirm and mark synced without re-uploading the bundle');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'17. Legacy Unverified Cloud Confirmation: Recovers as synced without re-upload and tracks count',
|
||||||
|
() async {
|
||||||
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
||||||
|
|
||||||
|
final rideId = await db.startRide(
|
||||||
|
startTime: DateTime.utc(2026, 10, 12, 14, 0, 0),
|
||||||
|
);
|
||||||
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
final remoteRideId = await db.ensureCloudRideId(rideId);
|
||||||
|
|
||||||
|
fakeProvider.confirmationResults[remoteRideId] =
|
||||||
|
CloudConfirmationResult.legacyUnverified(
|
||||||
|
remoteRideId: remoteRideId,
|
||||||
|
ownerUid: 'rider_alice',
|
||||||
|
message: 'Historical bundle without checksum',
|
||||||
|
);
|
||||||
|
|
||||||
|
final outbox = SyncOutbox(prefs);
|
||||||
|
final manager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [fakeProvider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
db: db,
|
||||||
|
authService: authService,
|
||||||
|
autoStart: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final report = await manager.reconcileCompletedRides();
|
||||||
|
|
||||||
|
expect(report.recoveredLegacyUnverifiedCount, equals(1));
|
||||||
|
expect(report.recoveredAlreadySyncedCount, equals(1));
|
||||||
|
expect(report.totalRecovered, equals(1));
|
||||||
|
final item = outbox.getItem(rideId, 'firebase');
|
||||||
|
expect(item?.status, equals(SyncStatus.synced));
|
||||||
|
expect(item?.lastError, contains('without modern checksum'));
|
||||||
|
expect(fakeProvider.uploadedItems.isEmpty, isTrue,
|
||||||
|
reason: 'Must never re-upload existing legacy unverified bundle');
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@ class _FakeAuthService implements AuthService {
|
||||||
final AuthUser _user;
|
final AuthUser _user;
|
||||||
|
|
||||||
_FakeAuthService([String uid = 'pilot_pixel_8a'])
|
_FakeAuthService([String uid = 'pilot_pixel_8a'])
|
||||||
: _user = AuthUser(
|
: _user = AuthUser(
|
||||||
uid: uid,
|
uid: uid,
|
||||||
email: '$uid@example.com',
|
email: '$uid@example.com',
|
||||||
displayName: 'Pixel Pilot',
|
displayName: 'Pixel Pilot',
|
||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AuthUser? get currentUser => _user;
|
AuthUser? get currentUser => _user;
|
||||||
|
|
@ -34,11 +34,7 @@ class _FakeAuthService implements AuthService {
|
||||||
bool get isEmailVerified => true;
|
bool get isEmailVerified => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> createUserWithEmailAndPassword(
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
||||||
String email,
|
|
||||||
String password, {
|
|
||||||
String? displayName,
|
|
||||||
}) async {}
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteCurrentUser() async {}
|
Future<void> deleteCurrentUser() async {}
|
||||||
@override
|
@override
|
||||||
|
|
@ -58,40 +54,45 @@ class _FakeAuthService implements AuthService {
|
||||||
@override
|
@override
|
||||||
Future<void> signInAnonymously() async {}
|
Future<void> signInAnonymously() async {}
|
||||||
@override
|
@override
|
||||||
Future<void> signInWithEmailAndPassword(
|
Future<void> signInWithEmailAndPassword(String email, String password) async {}
|
||||||
String email,
|
|
||||||
String password,
|
|
||||||
) async {}
|
|
||||||
@override
|
@override
|
||||||
Future<void> signInWithGoogle() async {}
|
Future<void> signInWithGoogle() async {}
|
||||||
@override
|
@override
|
||||||
Future<void> signOut() async {}
|
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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
test('Phase 6 — Disposable Backup Verification & Outbox Recovery Gate', () async {
|
final originalFile = _findBackupFile();
|
||||||
final originalFile = File(
|
|
||||||
r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
test(
|
||||||
originalFile.existsSync(),
|
'Phase 6 — Disposable Backup Verification & Outbox Recovery Gate',
|
||||||
isTrue,
|
() async {
|
||||||
reason: 'Original backup file must exist',
|
expect(originalFile, isNotNull, reason: 'Original backup file must exist when test is executed');
|
||||||
);
|
final file = originalFile!;
|
||||||
|
|
||||||
// 1. Verify original SHA-256 before copy
|
// 1. Verify original SHA-256 before copy
|
||||||
final originalBytes = await originalFile.readAsBytes();
|
final originalBytes = await file.readAsBytes();
|
||||||
final originalHash = sha256.convert(originalBytes).toString();
|
final originalHash = sha256.convert(originalBytes).toString();
|
||||||
const expectedHash =
|
const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122';
|
||||||
'7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122';
|
expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256');
|
||||||
expect(
|
|
||||||
originalHash,
|
|
||||||
equals(expectedHash),
|
|
||||||
reason: 'Original backup must match authoritative SHA-256',
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Create disposable copy in scratch directory
|
// 2. Create disposable copy in scratch directory
|
||||||
final scratchDir = Directory(
|
final scratchDir = Directory(
|
||||||
|
|
@ -100,13 +101,11 @@ void main() {
|
||||||
if (!scratchDir.existsSync()) {
|
if (!scratchDir.existsSync()) {
|
||||||
scratchDir.createSync(recursive: true);
|
scratchDir.createSync(recursive: true);
|
||||||
}
|
}
|
||||||
final disposableFile = File(
|
final disposableFile = File('${scratchDir.path}/disposable_motorsignal.sqlite');
|
||||||
'${scratchDir.path}/disposable_motorsignal.sqlite',
|
|
||||||
);
|
|
||||||
if (disposableFile.existsSync()) {
|
if (disposableFile.existsSync()) {
|
||||||
disposableFile.deleteSync();
|
disposableFile.deleteSync();
|
||||||
}
|
}
|
||||||
await originalFile.copy(disposableFile.path);
|
await file.copy(disposableFile.path);
|
||||||
expect(disposableFile.existsSync(), isTrue);
|
expect(disposableFile.existsSync(), isTrue);
|
||||||
|
|
||||||
// 3. Open disposable copy via Drift / SQLite
|
// 3. Open disposable copy via Drift / SQLite
|
||||||
|
|
@ -114,39 +113,21 @@ void main() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Integrity check
|
// Integrity check
|
||||||
final integrityRows = await db
|
final integrityRows = await db.customSelect('PRAGMA integrity_check').get();
|
||||||
.customSelect('PRAGMA integrity_check')
|
|
||||||
.get();
|
|
||||||
final integrityResult = integrityRows.first.data['integrity_check'];
|
final integrityResult = integrityRows.first.data['integrity_check'];
|
||||||
expect(
|
expect(integrityResult, equals('ok'), reason: 'SQLite integrity must be OK');
|
||||||
integrityResult,
|
|
||||||
equals('ok'),
|
|
||||||
reason: 'SQLite integrity must be OK',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Count rides and samples
|
// Count rides and samples
|
||||||
final rideCountRows = await db
|
final rideCountRows = await db.customSelect('SELECT COUNT(*) as count FROM rides').get();
|
||||||
.customSelect('SELECT COUNT(*) as count FROM rides')
|
|
||||||
.get();
|
|
||||||
final totalRides = rideCountRows.first.data['count'] as int;
|
final totalRides = rideCountRows.first.data['count'] as int;
|
||||||
expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides');
|
expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides');
|
||||||
|
|
||||||
final completedRidesRows = await db
|
final completedRidesRows = await db.customSelect('SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL').get();
|
||||||
.customSelect(
|
|
||||||
'SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL',
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
final completedRides = completedRidesRows.first.data['count'] as int;
|
final completedRides = completedRidesRows.first.data['count'] as int;
|
||||||
|
|
||||||
final samplesRows = await db
|
final samplesRows = await db.customSelect('SELECT COUNT(*) as count FROM telemetry_samples').get();
|
||||||
.customSelect('SELECT COUNT(*) as count FROM telemetry_samples')
|
|
||||||
.get();
|
|
||||||
final totalSamples = samplesRows.first.data['count'] as int;
|
final totalSamples = samplesRows.first.data['count'] as int;
|
||||||
expect(
|
expect(totalSamples, equals(67798), reason: 'Must contain exactly 67,798 telemetry samples');
|
||||||
totalSamples,
|
|
||||||
equals(67798),
|
|
||||||
reason: 'Must contain exactly 67,798 telemetry samples',
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Test Foreign Account Isolation against real Pixel backup
|
// 4. Test Foreign Account Isolation against real Pixel backup
|
||||||
// In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2'
|
// In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2'
|
||||||
|
|
@ -169,11 +150,7 @@ void main() {
|
||||||
final foreignReport = await foreignSyncManager.reconcileCompletedRides();
|
final foreignReport = await foreignSyncManager.reconcileCompletedRides();
|
||||||
expect(foreignReport.errorCount, equals(0));
|
expect(foreignReport.errorCount, equals(0));
|
||||||
expect(foreignReport.discoveredCompletedRides, equals(completedRides));
|
expect(foreignReport.discoveredCompletedRides, equals(completedRides));
|
||||||
expect(
|
expect(foreignReport.foreignOwnerSkippedCount, equals(completedRides), reason: 'Foreign account must NEVER claim or touch real Pixel rides');
|
||||||
foreignReport.foreignOwnerSkippedCount,
|
|
||||||
equals(completedRides),
|
|
||||||
reason: 'Foreign account must NEVER claim or touch real Pixel rides',
|
|
||||||
);
|
|
||||||
expect(foreignReport.totalRecovered, equals(0));
|
expect(foreignReport.totalRecovered, equals(0));
|
||||||
expect(outbox.count, equals(0));
|
expect(outbox.count, equals(0));
|
||||||
|
|
||||||
|
|
@ -200,17 +177,18 @@ void main() {
|
||||||
expect(report2.totalRecovered, equals(0));
|
expect(report2.totalRecovered, equals(0));
|
||||||
expect(report2.alreadyTrackedCount, equals(completedRides));
|
expect(report2.alreadyTrackedCount, equals(completedRides));
|
||||||
expect(outbox.count, equals(completedRides));
|
expect(outbox.count, equals(completedRides));
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
await db.close();
|
await db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Verify original SHA-256 after operations to prove zero mutation
|
// 6. Verify original SHA-256 after operations to prove zero mutation
|
||||||
final postBytes = await originalFile.readAsBytes();
|
final postBytes = await file.readAsBytes();
|
||||||
final postHash = sha256.convert(postBytes).toString();
|
final postHash = sha256.convert(postBytes).toString();
|
||||||
expect(
|
expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED');
|
||||||
postHash,
|
},
|
||||||
equals(expectedHash),
|
skip: originalFile == null
|
||||||
reason: 'Original backup MUST remain 100% UNTOUCHED',
|
? 'Real Pixel backup not present in this environment (e.g. CI runner)'
|
||||||
);
|
: null,
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue