Advertisement

← vibesdk

worker/api/controllers/cloudflareConnect/controller.test.ts

8.3 KB Download file
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CloudflareConnectController } from './controller';
import { signState, verifyState } from '../../../utils/stateSigning';
import { sha256Hash } from '../../../utils/cryptoUtils';
import type { RouteContext } from '../../types/route-context';

const {
	mockGetAuthorizationUrl,
	mockExchangeCodeForTokens,
	mockProvisionFromToken,
	mockAuthMiddleware,
} = vi.hoisted(() => ({
	mockGetAuthorizationUrl: vi.fn(),
	mockExchangeCodeForTokens: vi.fn(),
	mockProvisionFromToken: vi.fn(),
	mockAuthMiddleware: vi.fn(),
}));

vi.mock('../../../middleware/auth/auth', () => ({
	authMiddleware: mockAuthMiddleware,
}));

vi.mock('../../../services/oauth/cloudflare-connect', () => ({
	CloudflareConnectOAuthProvider: {
		create: vi.fn(() => ({
			getAuthorizationUrl: mockGetAuthorizationUrl,
			exchangeCodeForTokens: mockExchangeCodeForTokens,
		})),
	},
}));

vi.mock('../../../services/cloudflare/CloudflareProvisioningService', () => ({
	CloudflareProvisioningService: vi.fn().mockImplementation(() => ({
		provisionFromToken: mockProvisionFromToken,
	})),
}));

type TestEnv = Parameters<typeof CloudflareConnectController.initiateConnect>[1];
type TestExecutionContext = Parameters<typeof CloudflareConnectController.initiateConnect>[2];

const BASE_URL = 'https://app.local';
const FLOW_ID = 'a'.repeat(32);
const CSRF_TOKEN = 'test-csrf-token';

const testEnv = {
	ENVIRONMENT: 'dev',
	ENABLE_CLOUDFLARE_LIMITS: 'true',
	CF_OAUTH_ENCRYPTION_KEY: 'test-oauth-encryption-key-0123456789abcdef',
	CLOUDFLARE_OAUTH_CLIENT_ID: 'cf-client-id',
	CLOUDFLARE_OAUTH_CLIENT_SECRET: 'cf-client-secret',
} as unknown as TestEnv;

function makeContext(overrides: Partial<RouteContext> = {}): RouteContext {
	return {
		user: { id: 'user-1', email: 'user@example.com' },
		sessionId: 'session-1',
		config: {},
		pathParams: {},
		queryParams: new URLSearchParams(),
		...overrides,
	} as unknown as RouteContext;
}

function initiateRequest(overrides: {
	origin?: string;
	authorization?: string;
	includeCsrfHeader?: boolean;
	includeSessionCookie?: boolean;
} = {}): Request {
	const csrfCookie = encodeURIComponent(JSON.stringify({ token: CSRF_TOKEN, timestamp: Date.now() }));
	const cookies = [`csrf-token=${csrfCookie}`];
	if (overrides.includeSessionCookie !== false) cookies.unshift('accessToken=session-jwt');
	const headers = new Headers({
		'Content-Type': 'application/json',
		Origin: overrides.origin ?? BASE_URL,
		'Sec-Fetch-Site': 'same-origin',
		Cookie: cookies.join('; '),
	});
	if (overrides.includeCsrfHeader !== false) headers.set('X-CSRF-Token', CSRF_TOKEN);
	if (overrides.authorization) headers.set('Authorization', overrides.authorization);
	return new Request(`${BASE_URL}/api/cloudflare/connect`, {
		method: 'POST',
		headers,
		body: JSON.stringify({ returnUrl: '/settings?tab=cloudflare' }),
	});
}

async function connectState(overrides: Partial<{
	purpose: 'cloudflare-connect-v1';
	binding: string;
	flowId: string;
	timestamp: number;
	returnPath: string;
}> = {}) {
	return {
		purpose: 'cloudflare-connect-v1' as const,
		binding: await sha256Hash(`${FLOW_ID}:user-1:session-1`),
		flowId: FLOW_ID,
		timestamp: Date.now(),
		returnPath: '/settings',
		...overrides,
	};
}

describe('CloudflareConnectController.initiateConnect', () => {
	beforeEach(() => {
		vi.clearAllMocks();
		mockGetAuthorizationUrl.mockResolvedValue('https://dash.cloudflare.com/oauth2/authorize?fake=1');
	});

	it('returns an authorization URL and flow-scoped verifier cookie', async () => {
		const response = await CloudflareConnectController.initiateConnect(
			initiateRequest(),
			testEnv,
			{} as TestExecutionContext,
			makeContext(),
		);

		expect(response.status).toBe(200);
		expect(await response.json()).toMatchObject({
			success: true,
			data: { authUrl: 'https://dash.cloudflare.com/oauth2/authorize?fake=1' },
		});
		expect(response.headers.get('Set-Cookie')).toMatch(/cf_oauth_verifier_[a-f0-9]{32}=/);
		const signedState = mockGetAuthorizationUrl.mock.calls[0][0] as string;
		const state = await verifyState<Record<string, unknown> & { timestamp: number }>(signedState, testEnv);
		expect(state).toMatchObject({ purpose: 'cloudflare-connect-v1', returnPath: '/settings' });
		expect(state).not.toHaveProperty('userId');
		expect(state).not.toHaveProperty('sessionId');
	});

	it('rejects explicit bearer authentication for the browser-only flow', async () => {
		const response = await CloudflareConnectController.initiateConnect(
			initiateRequest({ authorization: 'Bearer session-jwt' }),
			testEnv,
			{} as TestExecutionContext,
			makeContext(),
		);

		expect(response.status).toBe(403);
		expect(mockGetAuthorizationUrl).not.toHaveBeenCalled();
	});

	it('rejects a mismatched origin', async () => {
		const response = await CloudflareConnectController.initiateConnect(
			initiateRequest({ origin: 'https://attacker.example' }),
			testEnv,
			{} as TestExecutionContext,
			makeContext(),
		);

		expect(response.status).toBe(403);
		expect(mockGetAuthorizationUrl).not.toHaveBeenCalled();
	});

	it('rejects a missing CSRF header', async () => {
		const response = await CloudflareConnectController.initiateConnect(
			initiateRequest({ includeCsrfHeader: false }),
			testEnv,
			{} as TestExecutionContext,
			makeContext(),
		);

		expect(response.status).toBe(403);
		expect(mockGetAuthorizationUrl).not.toHaveBeenCalled();
	});

	it('rejects a request without cookie authentication', async () => {
		const response = await CloudflareConnectController.initiateConnect(
			initiateRequest({ includeSessionCookie: false }),
			testEnv,
			{} as TestExecutionContext,
			makeContext(),
		);

		expect(response.status).toBe(403);
		expect(mockGetAuthorizationUrl).not.toHaveBeenCalled();
	});
});

describe('CloudflareConnectController.handleCallback', () => {
	beforeEach(() => {
		vi.clearAllMocks();
		mockAuthMiddleware.mockResolvedValue({
			user: { id: 'user-1', email: 'user@example.com' },
			sessionId: 'session-1',
		});
		mockExchangeCodeForTokens.mockResolvedValue({
			accessToken: 'cf-access-token',
			refreshToken: 'cf-refresh-token',
			expiresIn: 3600,
			tokenType: 'Bearer',
		});
		mockProvisionFromToken.mockResolvedValue({ accountCount: 2, hasActiveGateway: true });
	});

	it('returns to the normalized path on success', async () => {
		const state = await signState(await connectState(), testEnv);
		const request = new Request(
			`${BASE_URL}/auth/callback?code=auth-code&state=${encodeURIComponent(state)}`,
			{ headers: { Cookie: `accessToken=session-jwt; cf_oauth_verifier_${FLOW_ID}=test-verifier` } },
		);

		const response = await CloudflareConnectController.handleCallback(
			request,
			testEnv,
			{} as TestExecutionContext,
			makeContext({ sessionId: null }),
		);

		expect(response.status).toBe(302);
		const location = new URL(response.headers.get('Location')!);
		expect(location.searchParams.get('cloudflare')).toBe('connected');
		expect(location.searchParams.get('accounts')).toBe('2');
		expect(response.headers.get('Set-Cookie')).toContain(`cf_oauth_verifier_${FLOW_ID}=;`);
		expect(mockExchangeCodeForTokens).toHaveBeenCalledWith('auth-code', 'test-verifier');
		expect(mockProvisionFromToken).toHaveBeenCalledWith('cf-access-token', 'user-1');
	});

	it('rejects a callback completed by a different session before token exchange', async () => {
		mockAuthMiddleware.mockResolvedValue({
			user: { id: 'user-1', email: 'user@example.com' },
			sessionId: 'session-2',
		});
		const state = await signState(await connectState(), testEnv);
		const request = new Request(
			`${BASE_URL}/auth/callback?code=auth-code&state=${encodeURIComponent(state)}`,
			{ headers: { Cookie: `accessToken=session-jwt; cf_oauth_verifier_${FLOW_ID}=test-verifier` } },
		);

		const response = await CloudflareConnectController.handleCallback(
			request,
			testEnv,
			{} as TestExecutionContext,
			makeContext({ sessionId: null }),
		);

		expect(response.status).toBe(302);
		expect(new URL(response.headers.get('Location')!).searchParams.get('reason')).toBe('session_mismatch');
		expect(mockExchangeCodeForTokens).not.toHaveBeenCalled();
		expect(mockProvisionFromToken).not.toHaveBeenCalled();
	});
});