Merge pull request #39 from bponders12-cell/release/r2-worker-verification-20261014
feat(r2-worker): authenticated /r2/verify endpoint and lightweight metadata verification
This commit is contained in:
commit
25abf2815e
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const CORS_HEADERS = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version',
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version',
|
||||||
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256',
|
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256, X-MotorSignal-Size',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIREBASE_JWKS = new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com');
|
const FIREBASE_JWKS = new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com');
|
||||||
|
|
@ -146,10 +146,79 @@ export default {
|
||||||
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
||||||
const sha256 = object.customMetadata?.sha256;
|
const sha256 = object.customMetadata?.sha256;
|
||||||
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
||||||
|
headers.set('X-MotorSignal-Size', String(object.size));
|
||||||
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
||||||
return new Response(object.body, { status: 200, headers });
|
return new Response(object.body, { status: 200, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.method === 'HEAD' && path === '/r2/download') {
|
||||||
|
const remoteRideId = url.searchParams.get('rideId');
|
||||||
|
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
|
||||||
|
|
||||||
|
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 === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404);
|
||||||
|
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409);
|
||||||
|
|
||||||
|
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
||||||
|
const object = await env.R2_BUCKET.head(objectKey);
|
||||||
|
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404);
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
object.writeHttpMetadata(headers);
|
||||||
|
headers.set('etag', object.httpEtag);
|
||||||
|
headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`);
|
||||||
|
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
||||||
|
const sha256 = object.customMetadata?.sha256;
|
||||||
|
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
||||||
|
headers.set('X-MotorSignal-Size', String(object.size));
|
||||||
|
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
||||||
|
return new Response(null, { status: 200, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((request.method === 'GET' || request.method === 'HEAD') && path === '/r2/verify') {
|
||||||
|
const remoteRideId = url.searchParams.get('rideId');
|
||||||
|
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
|
||||||
|
|
||||||
|
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 === '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);
|
||||||
|
|
||||||
|
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
||||||
|
const object = await env.R2_BUCKET.head(objectKey);
|
||||||
|
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2', code: 'OBJECT_NOT_FOUND' }, 404);
|
||||||
|
|
||||||
|
const sha256 = object.customMetadata?.sha256;
|
||||||
|
const appVersion = object.customMetadata?.appVersion;
|
||||||
|
const uploadedAt = object.customMetadata?.uploadedAt;
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set('etag', object.httpEtag);
|
||||||
|
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
||||||
|
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
||||||
|
headers.set('X-MotorSignal-Size', String(object.size));
|
||||||
|
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
||||||
|
|
||||||
|
if (request.method === 'HEAD') {
|
||||||
|
return new Response(null, { status: 200, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
verified: true,
|
||||||
|
remoteRideId,
|
||||||
|
objectKey,
|
||||||
|
size: object.size,
|
||||||
|
etag: object.httpEtag,
|
||||||
|
sha256,
|
||||||
|
appVersion,
|
||||||
|
uploadedAt,
|
||||||
|
}), { status: 200, headers });
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({ error: 'Endpoint not found' }, 404);
|
return jsonResponse({ error: 'Endpoint not found' }, 404);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('MotorSignal Worker exception', err);
|
console.error('MotorSignal Worker exception', err);
|
||||||
|
|
@ -175,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> {
|
||||||
|
|
@ -185,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';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue