Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export class ResponsePaneComponent extends BaseComponent {
* host = http://localhost:8081 in the Local env) with a canned JSON body so a send
* lands a response in the pane without any live network.
*/
/** Resolves with the next request the playground sends to the `get users` URL. */
waitForUsersRequest(): Promise<Request> {
return this.page.waitForRequest('**/api/users**');
}

async mockUsersResponse(body: string): Promise<void> {
await this.page.route('**/api/users**', (route) =>
route.fulfill({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { test, expect } from '../../playwright';
import type { Page } from '@playwright/test';
import type { CodeEditorComponent } from '../../components/code-editor/code-editor.component';
import type { PlaygroundComponent } from '../../components/playground.component';

const SCRIPT_AUTHORIZATION = 'Bearer script-token';
const CONFIG_TOKEN = 'config-token';

const TAB_AUTHORIZATION = 'Bearer tab-token';

const SET_AUTHORIZATION_SCRIPT = `req.setHeader('authorization', '${SCRIPT_AUTHORIZATION}');`;
const SET_API_KEY_HEADER_SCRIPT = `req.setHeader('x-api-key', 'script-key');`;

const setEditorScript = async (page: Page, editor: CodeEditorComponent, script: string): Promise<void> => {
await editor.focus();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.insertText(script);
};

const addAuthorizationHeaderRow = async (playground: PlaygroundComponent): Promise<void> => {
await playground.selectTab('headers');
const { keyValueTable } = playground;
const rowIndex = (await keyValueTable.nameInputs.count()) - 1;
await keyValueTable.nameInputs.nth(rowIndex).fill('Authorization');
await keyValueTable.valueInputs.nth(rowIndex).fill(TAB_AUTHORIZATION);
};

test.describe('auth header precedence between the Headers tab, the Auth tab and a pre-request script', () => {
test.use({ viewport: { width: 1280, height: 900 } });

test.beforeEach(async ({ playground, responsePane }) => {
await responsePane.mockUsersResponse(JSON.stringify({ users: [] }));
await playground.open('bottom');
await playground.openRequest('get users');
await playground.selectTab('auth');
await playground.auth.selectMode('bearer');
await playground.auth.field('token').fill(CONFIG_TOKEN);
});

test('a pre-request script that sets Authorization sends the script value instead of the configured bearer token', async ({ page, playground, responsePane }) => {
await playground.selectTab('scripts');
await setEditorScript(page, playground.preRequestScriptEditor, SET_AUTHORIZATION_SCRIPT);

const sent = responsePane.waitForUsersRequest();
await responsePane.send();
const request = await sent;

expect(request.headers()['authorization']).toBe(SCRIPT_AUTHORIZATION);
});

test('an Authorization row in the Headers tab is overwritten by the configured bearer token, as on desktop', async ({ playground, responsePane }) => {
await addAuthorizationHeaderRow(playground);

const sent = responsePane.waitForUsersRequest();
await responsePane.send();
const request = await sent;

expect(request.headers()['authorization']).toBe(`Bearer ${CONFIG_TOKEN}`);
});

test('a pre-request script overwriting the Headers tab Authorization row wins over both tabs', async ({ page, playground, responsePane }) => {
await addAuthorizationHeaderRow(playground);
await playground.selectTab('scripts');
await setEditorScript(page, playground.preRequestScriptEditor, SET_AUTHORIZATION_SCRIPT);

const sent = responsePane.waitForUsersRequest();
await responsePane.send();
const request = await sent;

expect(request.headers()['authorization']).toBe(SCRIPT_AUTHORIZATION);
});

test('with Basic auth configured, a pre-request script Authorization header is overwritten, as on desktop', async ({ page, playground, responsePane }) => {
await playground.auth.selectMode('basic');
await playground.auth.field('username').fill('user');
await playground.auth.field('password').fill('pass');
await playground.selectTab('scripts');
await setEditorScript(page, playground.preRequestScriptEditor, SET_AUTHORIZATION_SCRIPT);

const sent = responsePane.waitForUsersRequest();
await responsePane.send();
const request = await sent;

expect(request.headers()['authorization']).toBe(`Basic ${Buffer.from('user:pass').toString('base64')}`);
});

test('with api key auth in header placement, a pre-request script setting that header wins', async ({ page, playground, responsePane }) => {
await playground.auth.selectMode('apikey');
await playground.auth.field('key').fill('X-API-Key');
await playground.auth.field('value').fill('config-key');
await playground.selectTab('scripts');
await setEditorScript(page, playground.preRequestScriptEditor, SET_API_KEY_HEADER_SCRIPT);

const sent = responsePane.waitForUsersRequest();
await responsePane.send();
const request = await sent;

expect(request.headers()['x-api-key']).toBe('script-key');
});

test('with No Auth selected, the Headers tab Authorization row is sent as typed', async ({ playground, responsePane }) => {
await playground.auth.selectMode('none');
await addAuthorizationHeaderRow(playground);

const sent = responsePane.waitForUsersRequest();
await responsePane.send();
const request = await sent;

expect(request.headers()['authorization']).toBe(TAB_AUTHORIZATION);
});

test('without a competing header the configured bearer token is sent', async ({ responsePane }) => {
const sent = responsePane.waitForUsersRequest();
await responsePane.send();
const request = await sent;

expect(request.headers()['authorization']).toBe(`Bearer ${CONFIG_TOKEN}`);
});
});
150 changes: 150 additions & 0 deletions packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import type { HttpRequest } from '@opencollection/types/requests/http';
import { RequestExecutor, applyApiKeyToUrl } from './RequestExecutor';
import type { InternalHttpRequest } from '@/utils/schemaHelpers';
import { md5 } from 'js-md5';

describe('applyApiKeyToUrl', () => {
Expand Down Expand Up @@ -453,3 +454,152 @@ describe('RequestExecutor digest auth', () => {
expect(fetchMock.mock.calls[0][1].credentials).toBeUndefined();
});
});

interface HeaderRow {
name: string;
value: string;
disabled?: boolean;
}

describe('RequestExecutor auth header precedence', () => {
const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});

const okResponse = () => ({
status: 200,
statusText: 'OK',
url: 'https://api.example.com/data',
headers: new Headers({ 'content-type': 'application/json' }),
text: async () => '{}',
arrayBuffer: async () => new TextEncoder().encode('{}').buffer
});

const sentHeaders = async (
auth: Record<string, unknown> | undefined,
headers: HeaderRow[] = [],
headersSetByScript: string[] = []
) => {
const fetchMock = vi.fn().mockResolvedValue(okResponse());
global.fetch = fetchMock as unknown as typeof fetch;

await new RequestExecutor().executeRequest({
name: 'auth precedence',
type: 'http',
http: { method: 'GET', url: 'https://api.example.com/data', auth, headers },
__brunoHeadersSetByScript: headersSetByScript
} as unknown as InternalHttpRequest);

return new Headers(fetchMock.mock.calls[0][1].headers as Record<string, string>);
};

const headerCount = (headers: Headers, name: string) => [...headers.keys()].filter((key) => key === name).length;

describe('a header from the Headers tab', () => {
it('is overwritten by configured bearer auth', async () => {
const headers = await sentHeaders(
{ type: 'bearer', token: 'config-token' },
[{ name: 'Authorization', value: 'Bearer tab-token', disabled: false }]
);

expect(headers.get('authorization')).toBe('Bearer config-token');
});

it('is overwritten by configured basic auth', async () => {
const headers = await sentHeaders(
{ type: 'basic', username: 'user', password: 'pass' },
[{ name: 'Authorization', value: 'Bearer tab-token', disabled: false }]
);

expect(headers.get('authorization')).toBe(`Basic ${btoa('user:pass')}`);
});

it('in another casing is replaced by the configured auth, not duplicated', async () => {
const headers = await sentHeaders(
{ type: 'bearer', token: 'config-token' },
[{ name: 'authorization', value: 'Bearer tab-token', disabled: false }]
);

expect(headers.get('authorization')).toBe('Bearer config-token');
expect(headerCount(headers, 'authorization')).toBe(1);
});

it('named like the api key in another casing is replaced by the configured api key, not duplicated', async () => {
const headers = await sentHeaders(
{ type: 'apikey', key: 'X-API-Key', value: 'config-key', placement: 'header' },
[{ name: 'x-api-key', value: 'tab-key', disabled: false }]
);

expect(headers.get('x-api-key')).toBe('config-key');
expect(headerCount(headers, 'x-api-key')).toBe(1);
});
});

describe('a header written by the pre-request script', () => {
it('wins over configured bearer auth', async () => {
const headers = await sentHeaders(
{ type: 'bearer', token: 'config-token' },
[{ name: 'Authorization', value: 'Bearer script-token', disabled: false }],
['authorization']
);

expect(headers.get('authorization')).toBe('Bearer script-token');
expect(headerCount(headers, 'authorization')).toBe(1);
});

it('is still overwritten by configured basic auth, as on desktop where basic auth is applied after the script', async () => {
const headers = await sentHeaders(
{ type: 'basic', username: 'user', password: 'pass' },
[{ name: 'Authorization', value: 'Bearer script-token', disabled: false }],
['authorization']
);

expect(headers.get('authorization')).toBe(`Basic ${btoa('user:pass')}`);
});

it('wins over configured api key auth in header placement, matching the key case-insensitively', async () => {
const headers = await sentHeaders(
{ type: 'apikey', key: 'X-API-Key', value: 'config-key', placement: 'header' },
[{ name: 'x-api-key', value: 'script-key', disabled: false }],
['x-api-key']
);

expect(headers.get('x-api-key')).toBe('script-key');
expect(headerCount(headers, 'x-api-key')).toBe(1);
});

it('does not shield a different header from the configured auth', async () => {
const headers = await sentHeaders(
{ type: 'bearer', token: 'config-token' },
[
{ name: 'X-Trace', value: 'from-script', disabled: false },
{ name: 'Authorization', value: 'Bearer tab-token', disabled: false }
],
['x-trace']
);

expect(headers.get('authorization')).toBe('Bearer config-token');
expect(headers.get('x-trace')).toBe('from-script');
});
});

it('still sends the configured bearer auth when no competing header exists', async () => {
const headers = await sentHeaders({ type: 'bearer', token: 'config-token' }, [
{ name: 'Accept', value: 'application/json', disabled: false }
]);

expect(headers.get('authorization')).toBe('Bearer config-token');
});

it('ignores a disabled Authorization header and sends the configured auth', async () => {
const headers = await sentHeaders(
{ type: 'bearer', token: 'config-token' },
[{ name: 'Authorization', value: 'Bearer stale-token', disabled: true }]
);

expect(headers.get('authorization')).toBe('Bearer config-token');
});
});
57 changes: 42 additions & 15 deletions packages/bruno-api-docs/src/runner/RequestExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ export const applyApiKeyToUrl = (url: string, auth: Record<string, unknown> | un
}
};

interface HeaderAuthConfig {
type?: string;
username?: string;
password?: string;
token?: string;
key?: string;
value?: string;
placement?: string;
}

const hasHeader = (headers: Record<string, string>, name: string): boolean => {
const lowerCaseName = name.toLowerCase();
return Object.keys(headers).some((key) => key.toLowerCase() === lowerCaseName);
};

const removeHeader = (headers: Record<string, string>, name: string): void => {
const lowerCaseName = name.toLowerCase();
Object.keys(headers).forEach((key) => {
if (key.toLowerCase() === lowerCaseName) delete headers[key];
});
};

export class RequestExecutor {
async executeRequest(request: InternalHttpRequest, options: { timeout?: number } = {}): Promise<RunRequestResponse> {
const startTime = Date.now();
Expand Down Expand Up @@ -101,10 +123,8 @@ export class RequestExecutor {
private async performFetch(url: string, fetchOptions: RequestInit, request: HttpRequest): Promise<Response> {
const credentials = getDigestCredentials(getRequestAuth(request));
const headers = fetchOptions.headers as Record<string, string>;
const hasManualAuthorization = Object.keys(headers).some((key) =>
key.toLowerCase() === 'authorization');

if (credentials === null || hasManualAuthorization) {
if (credentials === null || hasHeader(headers, 'Authorization')) {
return fetch(url, fetchOptions);
}

Expand Down Expand Up @@ -152,7 +172,7 @@ export class RequestExecutor {
return fetch(targetUrl, { ...fetchOptions, credentials: 'omit', headers: { ...headers, Authorization: result.header } });
}

private buildHeaders(request: HttpRequest): HeadersInit {
private buildHeaders(request: InternalHttpRequest): HeadersInit {
const headers: Record<string, string> = {};
const requestHeaders = getHttpHeaders(request);
const body = getHttpBody(request);
Expand All @@ -178,36 +198,43 @@ export class RequestExecutor {

// Let the browser set multipart/form-data with its boundary — drop any manual one.
if (body && 'type' in body && body.type === 'multipart-form') {
Object.keys(headers).forEach((key) => {
if (key.toLowerCase() === 'content-type') delete headers[key];
});
removeHeader(headers, 'Content-Type');
}

if (auth) {
this.setAuthHeaders(headers, auth);
this.setAuthHeaders(headers, auth, request.__brunoHeadersSetByScript ?? []);
}

return headers;
}

private setAuthHeaders(headers: Record<string, string>, auth: any) {
// The Auth tab overwrites a header from the Headers tab. Bearer and api key yield to a header the
// pre-request script wrote, basic does not: desktop applies bearer and api key before the script
// runs but encodes basic auth after it, so on desktop only basic overwrites a script header.
private setAuthHeaders(headers: Record<string, string>, auth: HeaderAuthConfig, headersSetByScript: string[]) {
const overwriteHeader = (name: string, value: string) => {
removeHeader(headers, name);
headers[name] = value;
};
const writeUnlessScriptSet = (name: string, value: string) => {
if (!headersSetByScript.includes(name.toLowerCase())) overwriteHeader(name, value);
};

switch (auth.type) {
case 'basic':
if (auth.username && auth.password) {
const credentials = btoa(`${auth.username}:${auth.password}`);
headers['Authorization'] = `Basic ${credentials}`;
overwriteHeader('Authorization', `Basic ${credentials}`);
}
break;
case 'bearer':
if (auth.token) {
headers['Authorization'] = `Bearer ${auth.token}`;
writeUnlessScriptSet('Authorization', `Bearer ${auth.token}`);
}
break;
case 'apikey':
if (auth.key && auth.value) {
if (auth.placement === 'header') {
headers[auth.key] = auth.value;
}
if (auth.key && auth.value && auth.placement === 'header') {
writeUnlessScriptSet(auth.key, auth.value);
}
break;
}
Expand Down
Loading
Loading