diff --git a/.changeset/wicked-points-send.md b/.changeset/wicked-points-send.md new file mode 100644 index 00000000..7e01d91d --- /dev/null +++ b/.changeset/wicked-points-send.md @@ -0,0 +1,5 @@ +--- +"@usebruno/api-docs": patch +--- + +fix(playground): request headers win over the Auth tab when both set the same header diff --git a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts index 0aab8505..f92f6da2 100644 --- a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts +++ b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts @@ -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 { + return this.page.waitForRequest('**/api/users**'); + } + async mockUsersResponse(body: string): Promise { await this.page.route('**/api/users**', (route) => route.fulfill({ diff --git a/packages/bruno-api-docs/e2e/tests/playground/script-auth-precedence.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/script-auth-precedence.spec.ts new file mode 100644 index 00000000..2af2c7d7 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/script-auth-precedence.spec.ts @@ -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 => { + await editor.focus(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.insertText(script); +}; + +const addAuthorizationHeaderRow = async (playground: PlaygroundComponent): Promise => { + 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}`); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts index 497fd0f7..b80056a8 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts @@ -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', () => { @@ -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 | 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); + }; + + 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'); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.ts index e3e24f88..165ef2cc 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.ts @@ -31,6 +31,28 @@ export const applyApiKeyToUrl = (url: string, auth: Record | un } }; +interface HeaderAuthConfig { + type?: string; + username?: string; + password?: string; + token?: string; + key?: string; + value?: string; + placement?: string; +} + +const hasHeader = (headers: Record, name: string): boolean => { + const lowerCaseName = name.toLowerCase(); + return Object.keys(headers).some((key) => key.toLowerCase() === lowerCaseName); +}; + +const removeHeader = (headers: Record, 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 { const startTime = Date.now(); @@ -101,10 +123,8 @@ export class RequestExecutor { private async performFetch(url: string, fetchOptions: RequestInit, request: HttpRequest): Promise { const credentials = getDigestCredentials(getRequestAuth(request)); const headers = fetchOptions.headers as Record; - const hasManualAuthorization = Object.keys(headers).some((key) => - key.toLowerCase() === 'authorization'); - if (credentials === null || hasManualAuthorization) { + if (credentials === null || hasHeader(headers, 'Authorization')) { return fetch(url, fetchOptions); } @@ -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 = {}; const requestHeaders = getHttpHeaders(request); const body = getHttpBody(request); @@ -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, 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, 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; } diff --git a/packages/bruno-api-docs/src/runner/index.ts b/packages/bruno-api-docs/src/runner/index.ts index d4024981..52920a85 100644 --- a/packages/bruno-api-docs/src/runner/index.ts +++ b/packages/bruno-api-docs/src/runner/index.ts @@ -8,6 +8,7 @@ import type { RunRequestCallback } from '@/scripting/utils/bru'; import AssertRuntime, { type AssertionResult } from '@/scripting/runtime/assert-runtime'; import { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeAuth, interpolateVars, findItemByPath } from './utils'; import { getCollectionFolderRequestVariables, getCollectionVariables } from './utils/variable-merger'; +import { snapshotEnabledHeaders, getHeaderNamesChangedByScript } from './utils/script-headers'; import { coerceVariableValue, parseValueByDataType, type CoercedVariableValue } from '@/utils/variableDataType'; import { externalSecretValues, type ExternalSecretEntry } from '@/utils/variableResolution'; import type { Variables, JsonValue } from './utils/variable-interpolator'; @@ -241,6 +242,7 @@ export class RequestRunner { try { const processedRequest: InternalHttpRequest = await this.preprocessRequest(item, collection); processedRequest.__bruno__executionMode = 'standalone'; + processedRequest.__brunoHeadersSetByScript = []; const { folderVariables, requestVariables } = getCollectionFolderRequestVariables(collection, processedRequest); @@ -263,6 +265,7 @@ export class RequestRunner { // Pre-request script if (scriptsObj.preRequest) { + const headersBeforeScript = snapshotEnabledHeaders(processedRequest); try { await this.scriptRuntime.runScript({ script: scriptsObj.preRequest, @@ -281,6 +284,10 @@ export class RequestRunner { warnings: warnings.length ? warnings : null }; } + processedRequest.__brunoHeadersSetByScript = getHeaderNamesChangedByScript( + headersBeforeScript, + processedRequest + ); } const interpolatedRequest = interpolateVars(processedRequest, allVariables); diff --git a/packages/bruno-api-docs/src/runner/req-mutations.spec.ts b/packages/bruno-api-docs/src/runner/req-mutations.spec.ts index a28a4074..a7b967cd 100644 --- a/packages/bruno-api-docs/src/runner/req-mutations.spec.ts +++ b/packages/bruno-api-docs/src/runner/req-mutations.spec.ts @@ -5,7 +5,7 @@ import { parseYaml } from '@/utils/yamlUtils'; interface SentRequest { url?: string; method?: string; - headers?: Headers; + headers: Headers; body?: unknown; timeoutArg?: number; } @@ -13,7 +13,7 @@ interface SentRequest { const sendWith = async (yaml: string, itemPath: number[] = [0]): Promise => { const originalFetch = global.fetch; const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); - let sent: SentRequest = {}; + let sent: SentRequest = { headers: new Headers() }; global.fetch = vi.fn().mockImplementation((url: string, init: RequestInit) => { sent = { url, method: init.method, headers: new Headers(init.headers), body: init.body }; return Promise.resolve({ @@ -87,55 +87,55 @@ describe('req.* mutations in a pre-request script reach the sent request', () => it('setHeader adds a new header and updates an existing one', async () => { const sent = await sendWith(preReq(`req.setHeader('X-A', 'updated');\nreq.setHeader('X-New', 'added');`)); - expect(sent.headers?.get('X-A')).toBe('updated'); - expect(sent.headers?.get('X-New')).toBe('added'); + expect(sent.headers.get('X-A')).toBe('updated'); + expect(sent.headers.get('X-New')).toBe('added'); }); it('setHeader updates case-insensitively without duplicating the header', async () => { const sent = await sendWith(preReq(`req.setHeader('content-type', 'application/xml');`)); const contentTypes = entries(sent.headers).filter(([k]) => k === 'content-type'); - expect(sent.headers?.get('content-type')).toBe('application/xml'); + expect(sent.headers.get('content-type')).toBe('application/xml'); expect(contentTypes).toHaveLength(1); }); it('setHeader coerces a non-string value to a string', async () => { const sent = await sendWith(preReq(`req.setHeader('X-Num', 42);`)); - expect(sent.headers?.get('X-Num')).toBe('42'); + expect(sent.headers.get('X-Num')).toBe('42'); }); it('deleteHeader and deleteHeaders remove headers', async () => { const sent = await sendWith(preReq(`req.deleteHeader('X-A');\nreq.deleteHeaders(['X-B']);`)); - expect(sent.headers?.has('X-A')).toBe(false); - expect(sent.headers?.has('X-B')).toBe(false); + expect(sent.headers.has('X-A')).toBe(false); + expect(sent.headers.has('X-B')).toBe(false); }); it('setHeaders replaces the enabled headers', async () => { const sent = await sendWith(preReq(`req.setHeaders({ 'X-Only': 'yes' });`)); - expect(sent.headers?.get('X-Only')).toBe('yes'); - expect(sent.headers?.has('X-A')).toBe(false); - expect(sent.headers?.has('X-B')).toBe(false); + expect(sent.headers.get('X-Only')).toBe('yes'); + expect(sent.headers.has('X-A')).toBe(false); + expect(sent.headers.has('X-B')).toBe(false); }); it('headerList add / upsert / remove / repopulate / clear reach the sent request', async () => { const added = await sendWith(preReq(`req.headerList.add('X-List', 'v');`)); - expect(added.headers?.get('X-List')).toBe('v'); + expect(added.headers.get('X-List')).toBe('v'); const upserted = await sendWith(preReq(`req.headerList.upsert('X-A', 'up');`)); - expect(upserted.headers?.get('X-A')).toBe('up'); + expect(upserted.headers.get('X-A')).toBe('up'); const removedByName = await sendWith(preReq(`req.headerList.remove('X-A');`)); - expect(removedByName.headers?.has('X-A')).toBe(false); + expect(removedByName.headers.has('X-A')).toBe(false); const removedByPredicate = await sendWith(preReq(`req.headerList.remove((h) => h.key === 'X-B');`)); - expect(removedByPredicate.headers?.has('X-B')).toBe(false); + expect(removedByPredicate.headers.has('X-B')).toBe(false); const repopulated = await sendWith(preReq(`req.headerList.repopulate([{ key: 'X-Fresh', value: 'f' }]);`)); - expect(repopulated.headers?.get('X-Fresh')).toBe('f'); - expect(repopulated.headers?.has('X-A')).toBe(false); + expect(repopulated.headers.get('X-Fresh')).toBe('f'); + expect(repopulated.headers.has('X-A')).toBe(false); const cleared = await sendWith(preReq(`req.headerList.clear();\nreq.headerList.add('X-Sole', '1');`)); - expect(cleared.headers?.get('X-Sole')).toBe('1'); - expect(cleared.headers?.has('X-A')).toBe(false); + expect(cleared.headers.get('X-Sole')).toBe('1'); + expect(cleared.headers.has('X-A')).toBe(false); }); it('setBody serialises an object and the request is sent as that JSON', async () => { @@ -151,7 +151,7 @@ describe('req.* mutations in a pre-request script reach the sent request', () => it('setBody with an object sets a JSON content-type even when the request had none', async () => { const sent = await sendWith(preReq(`req.setBody({ a: 1 });`, { method: 'POST', headers: '\n []' })); expect(String(sent.body)).toContain('"a":1'); - expect(sent.headers?.get('content-type')).toContain('application/json'); + expect(sent.headers.get('content-type')).toContain('application/json'); }); it('setBody with { raw: true } sends the string verbatim', async () => { @@ -182,7 +182,7 @@ describe('req.* mutations in a pre-request script reach the sent request', () => const sent = await sendWith( preReq(`req.setHeader('X-RW', '1');\nif (req.getHeader('X-RW') === '1' && req.getHeaders()['X-RW'] === '1') { req.setHeader('X-RW-OK', 'yes'); }`) ); - expect(sent.headers?.get('X-RW-OK')).toBe('yes'); + expect(sent.headers.get('X-RW-OK')).toBe('yes'); }); it('a collection-level pre-request script mutates the sent request', async () => { @@ -204,7 +204,7 @@ items: url: "https://api.example.com/base" `; const sent = await sendWith(yaml); - expect(sent.headers?.get('X-From-Collection')).toBe('yes'); + expect(sent.headers.get('X-From-Collection')).toBe('yes'); expect(sent.method).toBe('PATCH'); }); @@ -229,7 +229,222 @@ items: url: "https://api.example.com/base" `; const sent = await sendWith(yaml, [0, 0]); - expect(sent.headers?.get('X-From-Folder')).toBe('yes'); + expect(sent.headers.get('X-From-Folder')).toBe('yes'); + }); + + it('a pre-request script that sets Authorization wins over inherited collection bearer auth', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Script Auth Precedence" +request: + auth: + type: "bearer" + token: "collection-token" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: inherit + runtime: + scripts: + - type: before-request + code: | + req.setHeader('authorization', 'Bearer script-token'); +`; + const sent = await sendWith(yaml); + expect(sent.headers.get('authorization')).toBe('Bearer script-token'); + }); + + it('a pre-request script that sets the api key header wins over the request api key auth', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Script ApiKey Precedence" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: + type: "apikey" + key: "X-API-Key" + value: "config-key" + placement: "header" + runtime: + scripts: + - type: before-request + code: | + req.setHeader('X-API-Key', 'script-key'); +`; + const sent = await sendWith(yaml); + expect(sent.headers.get('x-api-key')).toBe('script-key'); + }); + + it('folder-level inherited basic auth overwrites a pre-request script Authorization header, as on desktop', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Folder Auth Precedence" +items: + - name: "folder" + type: "folder" + request: + auth: + type: "basic" + username: "user" + password: "pass" + items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: inherit + runtime: + scripts: + - type: before-request + code: | + req.setHeader('AUTHORIZATION', 'Bearer script-token'); +`; + const sent = await sendWith(yaml, [0, 0]); + expect(sent.headers.get('authorization')).toBe(`Basic ${btoa('user:pass')}`); + }); + + it('a Headers tab Authorization entry is overwritten by the request bearer auth when no script touches it', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Auth Tab Beats Headers Tab" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + headers: + - name: "Authorization" + value: "Bearer tab-token" + auth: + type: "bearer" + token: "config-token" + runtime: + scripts: + - type: before-request + code: | + req.setHeader('X-Other', 'set-by-script'); +`; + const sent = await sendWith(yaml); + expect(sent.headers.get('authorization')).toBe('Bearer config-token'); + expect(sent.headers.get('x-other')).toBe('set-by-script'); + }); + + it('a pre-request script that overwrites the Headers tab Authorization entry wins over the request bearer auth', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Script Beats Both Tabs" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + headers: + - name: "Authorization" + value: "Bearer tab-token" + auth: + type: "bearer" + token: "config-token" + runtime: + scripts: + - type: before-request + code: | + req.setHeader('Authorization', 'Bearer script-token'); +`; + const sent = await sendWith(yaml); + expect(sent.headers.get('authorization')).toBe('Bearer script-token'); + }); + + it('a collection cannot pre-declare script-written headers to suppress the configured auth', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Injected Script Header List" +items: + - name: "r" + type: "http" + __brunoHeadersSetByScript: ["authorization"] + http: + method: "GET" + url: "https://api.example.com/base" + headers: + - name: "Authorization" + value: "Bearer tab-token" + auth: + type: "bearer" + token: "config-token" +`; + const sent = await sendWith(yaml); + expect(sent.headers.get('authorization')).toBe('Bearer config-token'); + }); + + it('a pre-request script overwriting a header that has duplicate rows sends the script value once, with no auth configured', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Duplicate Rows Collapse" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + headers: + - name: "X-Dup" + value: "row-one" + - name: "X-Dup" + value: "row-two" + runtime: + scripts: + - type: before-request + code: | + req.setHeader('X-Dup', 'script'); +`; + const sent = await sendWith(yaml); + expect(sent.headers.get('x-dup')).toBe('script'); + }); + + it('a pre-request script overwriting duplicate Authorization rows wins over the configured bearer auth', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Duplicate Rows Then Script" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + headers: + - name: "Authorization" + value: "Bearer row-one" + - name: "Authorization" + value: "Bearer row-two" + auth: + type: "bearer" + token: "config-token" + runtime: + scripts: + - type: before-request + code: | + req.setHeader('Authorization', 'Bearer script-token'); +`; + const sent = await sendWith(yaml); + expect(sent.headers.get('authorization')).toBe('Bearer script-token'); }); it('editing an inherited header in a pre-request script stays request-local and does not corrupt the shared collection config', async () => { @@ -254,7 +469,7 @@ items: req.setHeader('X-Inherited', 'mutated'); `; const originalFetch = global.fetch; - let sent: SentRequest = {}; + let sent: SentRequest = { headers: new Headers() }; global.fetch = vi.fn().mockImplementation((url: string, init: RequestInit) => { sent = { url, method: init.method, headers: new Headers(init.headers), body: init.body }; return Promise.resolve({ @@ -275,10 +490,8 @@ items: }); global.fetch = originalFetch; - const inheritedHeaders - = (collection as { request?: { headers?: Array<{ name: string; value: string }> } }).request?.headers ?? []; - const inherited = inheritedHeaders.find((h) => h.name === 'X-Inherited'); - expect(sent.headers?.get('X-Inherited')).toBe('mutated'); - expect(inherited?.value).toBe('original'); + const parsedCollection = collection as { request: { headers: Array<{ name: string; value: string }> } }; + expect(sent.headers.get('X-Inherited')).toBe('mutated'); + expect(parsedCollection.request.headers).toEqual([{ name: 'X-Inherited', value: 'original' }]); }); }); diff --git a/packages/bruno-api-docs/src/runner/utils/script-headers.spec.ts b/packages/bruno-api-docs/src/runner/utils/script-headers.spec.ts new file mode 100644 index 00000000..d035bdf1 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/utils/script-headers.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import type { HttpRequest } from '@opencollection/types/requests/http'; +import { snapshotEnabledHeaders, getHeaderNamesChangedByScript } from './script-headers'; + +interface HeaderRow { + name: string; + value: string; + disabled?: boolean; +} + +const requestWith = (headers: HeaderRow[]) => ({ + name: 'r', + type: 'http', + http: { method: 'GET', url: 'https://api.example.com', headers } +} as unknown as HttpRequest); + +describe('snapshotEnabledHeaders', () => { + it('records each enabled header as a lower-cased name and value pair', () => { + const snapshot = snapshotEnabledHeaders(requestWith([ + { name: 'Authorization', value: 'Bearer a' }, + { name: 'X-Off', value: 'no', disabled: true } + ])); + + expect([...snapshot]).toEqual(['authorization\nBearer a']); + }); + + it('ignores rows without a name', () => { + expect(snapshotEnabledHeaders(requestWith([{ name: '', value: 'x' }])).size).toBe(0); + }); + + it('is empty for a request without headers', () => { + expect(snapshotEnabledHeaders(requestWith([])).size).toBe(0); + }); +}); + +describe('getHeaderNamesChangedByScript', () => { + it('returns headers the script added', () => { + const before = snapshotEnabledHeaders(requestWith([{ name: 'Accept', value: 'json' }])); + const after = requestWith([{ name: 'Accept', value: 'json' }, { name: 'Authorization', value: 'Bearer s' }]); + + expect(getHeaderNamesChangedByScript(before, after)).toEqual(['authorization']); + }); + + it('returns headers whose value the script changed', () => { + const before = snapshotEnabledHeaders(requestWith([{ name: 'Authorization', value: 'Bearer tab' }])); + const after = requestWith([{ name: 'Authorization', value: 'Bearer script' }]); + + expect(getHeaderNamesChangedByScript(before, after)).toEqual(['authorization']); + }); + + it('matches a re-cased header against the snapshot without reporting it', () => { + const before = snapshotEnabledHeaders(requestWith([{ name: 'Authorization', value: 'Bearer tab' }])); + const after = requestWith([{ name: 'authorization', value: 'Bearer tab' }]); + + expect(getHeaderNamesChangedByScript(before, after)).toEqual([]); + }); + + it('does not report duplicate same-name rows that were all present before the script', () => { + const rows: HeaderRow[] = [ + { name: 'Authorization', value: 'Bearer row-one' }, + { name: 'Authorization', value: 'Bearer row-two' } + ]; + const before = snapshotEnabledHeaders(requestWith(rows)); + + expect(getHeaderNamesChangedByScript(before, requestWith(rows))).toEqual([]); + }); + + it('ignores headers the script left untouched, removed, disabled, or left unnamed', () => { + const before = snapshotEnabledHeaders(requestWith([ + { name: 'Accept', value: 'json' }, + { name: 'X-Gone', value: '1' }, + { name: 'X-Off', value: '2' } + ])); + const after = requestWith([ + { name: 'Accept', value: 'json' }, + { name: 'X-Off', value: '2', disabled: true }, + { name: '', value: 'unnamed' } + ]); + + expect(getHeaderNamesChangedByScript(before, after)).toEqual([]); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/utils/script-headers.ts b/packages/bruno-api-docs/src/runner/utils/script-headers.ts new file mode 100644 index 00000000..051ae630 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/utils/script-headers.ts @@ -0,0 +1,26 @@ +import type { HttpRequest } from '@opencollection/types/requests/http'; +import { getHttpHeaders } from '@/utils/schemaHelpers'; + +export type HeaderSnapshot = Set; + +const headerPairKey = (name: string, value: string): string => `${name.toLowerCase()}\n${value}`; + +export const snapshotEnabledHeaders = (request: HttpRequest): HeaderSnapshot => { + const snapshot: HeaderSnapshot = new Set(); + getHttpHeaders(request).forEach((header) => { + if (!header.disabled && header.name) snapshot.add(headerPairKey(header.name, header.value)); + }); + return snapshot; +}; + +// Names of the headers the pre-request script added or changed, compared against the snapshot +// taken before it ran. A header counts as untouched only when the same name and value pair was +// already there, so two rows sharing a name are not mistaken for a script edit. +export const getHeaderNamesChangedByScript = (before: HeaderSnapshot, request: HttpRequest): string[] => { + const changed = new Set(); + getHttpHeaders(request).forEach((header) => { + if (header.disabled || !header.name) return; + if (!before.has(headerPairKey(header.name, header.value))) changed.add(header.name.toLowerCase()); + }); + return [...changed]; +}; diff --git a/packages/bruno-api-docs/src/scripting/utils/bruno-request.ts b/packages/bruno-api-docs/src/scripting/utils/bruno-request.ts index c576014a..49835b55 100644 --- a/packages/bruno-api-docs/src/scripting/utils/bruno-request.ts +++ b/packages/bruno-api-docs/src/scripting/utils/bruno-request.ts @@ -141,13 +141,7 @@ class BrunoRequest { } setHeader(name: string, value: string) { - const list = this.headersArray(); - const existing = list.find((h) => !h.disabled && sameName(h.name, name)); - if (existing) { - existing.value = String(value ?? ''); - } else { - list.push({ name, value: String(value ?? '') }); - } + this.headerList.upsert(name, value); } deleteHeader(name: string) { diff --git a/packages/bruno-api-docs/src/scripting/utils/header-list.spec.ts b/packages/bruno-api-docs/src/scripting/utils/header-list.spec.ts index 990c9d2d..62f9bc56 100644 --- a/packages/bruno-api-docs/src/scripting/utils/header-list.spec.ts +++ b/packages/bruno-api-docs/src/scripting/utils/header-list.spec.ts @@ -154,6 +154,21 @@ describe('createRequestHeaderList (writable request headers)', () => { expect(arr.filter((h) => h.name.toLowerCase() === 'x-token')).toHaveLength(1); }); + it('upsert collapses duplicate enabled rows of the same key into one row holding the new value', () => { + const { list, arr } = make([ + { name: 'Authorization', value: 'row-one' }, + { name: 'Authorization', value: 'row-two' }, + { name: 'Authorization', value: 'off', disabled: true } + ]); + + expect(list.upsert('authorization', 'script')).toBe(false); + + expect(arr.filter((h) => !h.disabled && h.name.toLowerCase() === 'authorization')).toEqual([ + { name: 'authorization', value: 'script' } + ]); + expect(arr.filter((h) => h.disabled)).toHaveLength(1); + }); + it('remove deletes by string key, by object, and by predicate', () => { const { list, arr } = make([{ name: 'A', value: '1' }, { name: 'B', value: '2' }, { name: 'C', value: '3' }]); list.remove('a'); diff --git a/packages/bruno-api-docs/src/scripting/utils/header-list.ts b/packages/bruno-api-docs/src/scripting/utils/header-list.ts index 25b249fa..8b05f5d4 100644 --- a/packages/bruno-api-docs/src/scripting/utils/header-list.ts +++ b/packages/bruno-api-docs/src/scripting/utils/header-list.ts @@ -149,6 +149,9 @@ export const createRequestHeaderList = (getHeaders: () => RequestHeaderEntry[]): getHeaders().map((h) => ({ key: h.name, value: h.value, disabled: h.disabled })); const hasKey = (name: string): boolean => getHeaders().some((h) => eqKey(h.name, name)); + // The Headers tab can hold several enabled rows with the same name, and the executor sends the + // last one. A script write replaces the header, so besides updating the first matching row this + // drops the other enabled rows with that name, or the script's value would never reach the wire. const upsert = (itemOrName: HeaderInput, value?: string): boolean | null => { const item = typeof itemOrName === 'string' ? { key: itemOrName, value } : itemOrName; if (!item || typeof item !== 'object' || !item.key) return null; @@ -157,6 +160,9 @@ export const createRequestHeaderList = (getHeaders: () => RequestHeaderEntry[]): if (existing) { existing.name = item.key; existing.value = String(item.value ?? ''); + for (let i = list.length - 1; i >= 0; i--) { + if (list[i] !== existing && !list[i].disabled && eqKey(list[i].name, item.key)) list.splice(i, 1); + } return false; } list.push({ name: item.key, value: String(item.value ?? '') }); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index 8301b32a..39f314f9 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -30,6 +30,7 @@ export type RequestBody = HttpRequestBody | HttpRequestBodyVariant[] | undefined export type InternalHttpRequest = HttpRequest & { __brunoDisableParsingResponseJson?: boolean; __bruno__executionMode?: string; + __brunoHeadersSetByScript?: string[]; timeout?: number | 'inherit'; };