From bd7d7f1ee3c73d7475f38e445219f9c9d31ac3c2 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 14 Sep 2026 17:12:05 +0530 Subject: [PATCH 01/11] fix(playground): request headers win over the Auth tab when both set the same header A pre-request script that set its own Authorization header had it overwritten by the bearer, basic or api key auth configured on the request. The executor now skips the configured auth header when the request already carries one with the same name, matching the desktop app where the script runs after auth is applied. --- .../playground/script-auth-precedence.spec.ts | 48 +++ .../src/runner/RequestExecutor.spec.ts | 98 ++++++ .../src/runner/RequestExecutor.ts | 21 +- .../src/runner/req-mutations.spec.ts | 287 ++++++++++++++++++ 4 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/tests/playground/script-auth-precedence.spec.ts 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..cf93f82f --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/script-auth-precedence.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '../../playwright'; +import type { Page } from '@playwright/test'; +import type { CodeEditorComponent } from '../../components/code-editor/code-editor.component'; + +const SCRIPT_AUTHORIZATION = 'Bearer script-token'; +const CONFIG_TOKEN = 'config-token'; + +const SET_AUTHORIZATION_SCRIPT = `req.setHeader('authorization', '${SCRIPT_AUTHORIZATION}');`; + +const setEditorScript = async (page: Page, editor: CodeEditorComponent, script: string): Promise => { + await editor.focus(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.insertText(script); +}; + +test.describe('auth header precedence between 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 = page.waitForRequest('**/api/users**'); + await responsePane.send(); + const request = await sent; + + expect(request.headers()['authorization']).toBe(SCRIPT_AUTHORIZATION); + await expect(responsePane.status).toContainText('200'); + }); + + test('without a competing header the configured bearer token is sent', async ({ page, responsePane }) => { + const sent = page.waitForRequest('**/api/users**'); + await responsePane.send(); + const request = await sent; + + expect(request.headers()['authorization']).toBe(`Bearer ${CONFIG_TOKEN}`); + await expect(responsePane.status).toContainText('200'); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts index 497fd0f7..8e62d874 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts @@ -453,3 +453,101 @@ describe('RequestExecutor digest auth', () => { expect(fetchMock.mock.calls[0][1].credentials).toBeUndefined(); }); }); + +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: unknown[] = []) => { + 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 } + } as unknown as HttpRequest); + + return new Headers(fetchMock.mock.calls[0][1].headers as Record); + }; + + it('keeps a request Authorization header over configured bearer auth', async () => { + const headers = await sentHeaders( + { type: 'bearer', token: 'config-token' }, + [{ name: 'Authorization', value: 'Bearer script-token', disabled: false }] + ); + + expect(headers.get('authorization')).toBe('Bearer script-token'); + }); + + it('keeps a request Authorization header over configured basic auth', async () => { + const headers = await sentHeaders( + { type: 'basic', username: 'user', password: 'pass' }, + [{ name: 'Authorization', value: 'Bearer script-token', disabled: false }] + ); + + expect(headers.get('authorization')).toBe('Bearer script-token'); + }); + + it('matches the Authorization header case-insensitively', async () => { + const headers = await sentHeaders( + { type: 'bearer', token: 'config-token' }, + [{ name: 'authorization', value: 'Bearer script-token', disabled: false }] + ); + + expect(headers.get('authorization')).toBe('Bearer script-token'); + expect([...headers.keys()].filter((key) => key === 'authorization')).toHaveLength(1); + }); + + it('keeps a request header 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 }] + ); + + expect(headers.get('x-api-key')).toBe('script-key'); + expect([...headers.keys()].filter((key) => key === 'x-api-key')).toHaveLength(1); + }); + + 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('still sends the configured api key header when no competing header exists', async () => { + const headers = await sentHeaders({ type: 'apikey', key: 'X-API-Key', value: 'config-key', placement: 'header' }); + + expect(headers.get('x-api-key')).toBe('config-key'); + }); + + 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'); + }); + + it('sends no auth header when the request has no auth and no Authorization header', async () => { + const headers = await sentHeaders(undefined, [{ name: 'Accept', value: 'application/json', disabled: false }]); + + expect(headers.has('authorization')).toBe(false); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.ts index e3e24f88..da925224 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.ts @@ -31,6 +31,11 @@ export const applyApiKeyToUrl = (url: string, auth: Record | un } }; +const hasHeader = (headers: Record, name: string): boolean => { + const lowerCaseName = name.toLowerCase(); + return Object.keys(headers).some((key) => key.toLowerCase() === lowerCaseName); +}; + export class RequestExecutor { async executeRequest(request: InternalHttpRequest, options: { timeout?: number } = {}): Promise { const startTime = Date.now(); @@ -101,10 +106,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); } @@ -190,24 +193,24 @@ export class RequestExecutor { return headers; } + // A header the request already carries (from the Headers tab or a pre-request script) wins over + // the Auth tab, as on desktop where the script runs after auth is applied and overwrites it. private setAuthHeaders(headers: Record, auth: any) { switch (auth.type) { case 'basic': - if (auth.username && auth.password) { + if (auth.username && auth.password && !hasHeader(headers, 'Authorization')) { const credentials = btoa(`${auth.username}:${auth.password}`); headers['Authorization'] = `Basic ${credentials}`; } break; case 'bearer': - if (auth.token) { + if (auth.token && !hasHeader(headers, 'Authorization')) { headers['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' && !hasHeader(headers, auth.key)) { + headers[auth.key] = auth.value; } break; } 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..a79aa860 100644 --- a/packages/bruno-api-docs/src/runner/req-mutations.spec.ts +++ b/packages/bruno-api-docs/src/runner/req-mutations.spec.ts @@ -8,6 +8,7 @@ interface SentRequest { headers?: Headers; body?: unknown; timeoutArg?: number; + fetchCalls?: number; } const sendWith = async (yaml: string, itemPath: number[] = [0]): Promise => { @@ -38,6 +39,7 @@ const sendWith = async (yaml: string, itemPath: number[] = [0]): Promise 0 ? calls[calls.length - 1] : null; if (call) sent.timeoutArg = call[0]; + sent.fetchCalls = (global.fetch as ReturnType).mock.calls.length; timeoutSpy.mockRestore(); global.fetch = originalFetch; return sent; @@ -232,6 +234,291 @@ items: 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('a pre-request script that sets Authorization on a digest request is sent as-is with no challenge round trip', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Script Digest Precedence" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: + type: "digest" + username: "user" + password: "pass" + 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'); + expect(sent.fetchCalls).toBe(1); + }); + + it('a pre-request script that sets Authorization wins over folder-level inherited basic auth', 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('Bearer script-token'); + }); + + it('a pre-request script that deletes the Authorization header lets the configured auth apply', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Delete Header Restores Auth" +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.deleteHeader('authorization'); +`; + const sent = await sendWith(yaml); + expect(sent.headers?.get('authorization')).toBe('Bearer config-token'); + }); + + it('a pre-request script that sets Authorization to an empty string lets the configured auth apply', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Empty Header Falls Back To Auth" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: + type: "bearer" + token: "config-token" + runtime: + scripts: + - type: before-request + code: | + req.setHeader('Authorization', ''); +`; + const sent = await sendWith(yaml); + expect(sent.headers?.get('authorization')).toBe('Bearer config-token'); + }); + + it('a pre-request script header value is interpolated before it is compared with the configured auth', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Interpolated Script Header" +request: + variables: + - name: "scriptToken" + value: "resolved-token" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: + type: "bearer" + token: "config-token" + runtime: + scripts: + - type: before-request + code: | + req.setHeader('Authorization', 'Bearer {{scriptToken}}'); +`; + const sent = await sendWith(yaml); + expect(sent.headers?.get('authorization')).toBe('Bearer resolved-token'); + }); + + it('a collection-level Authorization header wins over the request bearer auth', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Inherited Header Beats Request Auth" +request: + headers: + - name: "Authorization" + value: "Bearer collection-header-token" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: + type: "bearer" + token: "config-token" +`; + const sent = await sendWith(yaml); + expect(sent.headers?.get('authorization')).toBe('Bearer collection-header-token'); + }); + + it('a script header named like the api key still leaves the query-placement api key on the url', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Query ApiKey Untouched" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: + type: "apikey" + key: "api_key" + value: "config-key" + placement: "query" + runtime: + scripts: + - type: before-request + code: | + req.setHeader('api_key', 'script-key'); +`; + const sent = await sendWith(yaml); + expect(sent.url).toBe('https://api.example.com/base?api_key=config-key'); + expect(sent.headers?.get('api_key')).toBe('script-key'); + }); + + it('req.setHeaders replacing all headers with an Authorization entry wins over the configured auth', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Bulk Headers Precedence" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: + type: "bearer" + token: "config-token" + runtime: + scripts: + - type: before-request + code: | + req.setHeaders({ Authorization: 'Bearer bulk-token' }); +`; + const sent = await sendWith(yaml); + expect(sent.headers?.get('authorization')).toBe('Bearer bulk-token'); + }); + + it('an incomplete bearer config writes nothing and leaves a script header untouched', async () => { + const yaml = ` +opencollection: "1.0.0" +info: + name: "Incomplete Auth" +items: + - name: "r" + type: "http" + http: + method: "GET" + url: "https://api.example.com/base" + auth: + type: "bearer" + 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 () => { const yaml = ` opencollection: "1.0.0" From 8eeb129b8b17ad57f757bfb6a76ec2f7d276fa7c Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 14 Sep 2026 20:21:41 +0530 Subject: [PATCH 02/11] fix(playground): keep Auth tab over Headers tab and basic auth over script headers, as on desktop The previous commit let any existing header beat the Auth tab, which also let a Headers tab row win. The runner now records which header names the pre-request script wrote, and the executor only yields bearer and api key auth to those. Basic auth always overwrites, since desktop encodes it after the script runs. --- .../playground/script-auth-precedence.spec.ts | 60 ++++++++- .../src/runner/RequestExecutor.spec.ts | 127 +++++++++++++----- .../src/runner/RequestExecutor.ts | 52 +++++-- packages/bruno-api-docs/src/runner/index.ts | 3 + .../src/runner/req-mutations.spec.ts | 71 ++++++++-- .../src/runner/utils/script-headers.spec.ts | 58 ++++++++ .../src/runner/utils/script-headers.ts | 24 ++++ .../bruno-api-docs/src/utils/schemaHelpers.ts | 1 + 8 files changed, 343 insertions(+), 53 deletions(-) create mode 100644 packages/bruno-api-docs/src/runner/utils/script-headers.spec.ts create mode 100644 packages/bruno-api-docs/src/runner/utils/script-headers.ts 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 index cf93f82f..df0a6405 100644 --- 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 @@ -1,10 +1,13 @@ 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 setEditorScript = async (page: Page, editor: CodeEditorComponent, script: string): Promise => { @@ -13,7 +16,15 @@ const setEditorScript = async (page: Page, editor: CodeEditorComponent, script: await page.keyboard.insertText(script); }; -test.describe('auth header precedence between the Auth tab and a pre-request 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 }) => { @@ -37,6 +48,53 @@ test.describe('auth header precedence between the Auth tab and a pre-request scr await expect(responsePane.status).toContainText('200'); }); + test('an Authorization row in the Headers tab is overwritten by the configured bearer token, as on desktop', async ({ page, playground, responsePane }) => { + await addAuthorizationHeaderRow(playground); + + const sent = page.waitForRequest('**/api/users**'); + 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 = page.waitForRequest('**/api/users**'); + 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 = page.waitForRequest('**/api/users**'); + await responsePane.send(); + const request = await sent; + + expect(request.headers()['authorization']).toBe(`Basic ${Buffer.from('user:pass').toString('base64')}`); + }); + + test('with No Auth selected, the Headers tab Authorization row is sent as typed', async ({ page, playground, responsePane }) => { + await playground.auth.selectMode('none'); + await addAuthorizationHeaderRow(playground); + + const sent = page.waitForRequest('**/api/users**'); + 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 ({ page, responsePane }) => { const sent = page.waitForRequest('**/api/users**'); await responsePane.send(); diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts index 8e62d874..97f71885 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts @@ -471,55 +471,122 @@ describe('RequestExecutor auth header precedence', () => { arrayBuffer: async () => new TextEncoder().encode('{}').buffer }); - const sentHeaders = async (auth: Record | undefined, headers: unknown[] = []) => { + const sentHeaders = async ( + auth: Record | undefined, + headers: unknown[] = [], + 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 } + http: { method: 'GET', url: 'https://api.example.com/data', auth, headers }, + __brunoHeadersSetByScript: headersSetByScript } as unknown as HttpRequest); return new Headers(fetchMock.mock.calls[0][1].headers as Record); }; - it('keeps a request Authorization header over configured bearer auth', async () => { - const headers = await sentHeaders( - { type: 'bearer', token: 'config-token' }, - [{ name: 'Authorization', value: 'Bearer script-token', disabled: false }] - ); + const headerCount = (headers: Headers, name: string) => [...headers.keys()].filter((key) => key === name).length; - expect(headers.get('authorization')).toBe('Bearer script-token'); - }); + 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 }] + ); - it('keeps a request Authorization header over configured basic auth', async () => { - const headers = await sentHeaders( - { type: 'basic', username: 'user', password: 'pass' }, - [{ name: 'Authorization', value: 'Bearer script-token', disabled: false }] - ); + expect(headers.get('authorization')).toBe('Bearer config-token'); + }); - expect(headers.get('authorization')).toBe('Bearer script-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 }] + ); - it('matches the Authorization header case-insensitively', async () => { - const headers = await sentHeaders( - { type: 'bearer', token: 'config-token' }, - [{ name: 'authorization', value: 'Bearer script-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('authorization')).toBe('Bearer script-token'); - expect([...headers.keys()].filter((key) => key === 'authorization')).toHaveLength(1); + expect(headers.get('x-api-key')).toBe('config-key'); + expect(headerCount(headers, 'x-api-key')).toBe(1); + }); }); - it('keeps a request header 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 }] - ); + 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'); + }); + + 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')}`); + }); - expect(headers.get('x-api-key')).toBe('script-key'); - expect([...headers.keys()].filter((key) => key === 'x-api-key')).toHaveLength(1); + it('in another casing wins over configured bearer auth without duplication', 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('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 () => { diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.ts index da925224..410be0d0 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.ts @@ -36,6 +36,23 @@ const hasHeader = (headers: Record, name: string): boolean => { return Object.keys(headers).some((key) => key.toLowerCase() === lowerCaseName); }; +interface HeaderAuthConfig { + type?: string; + username?: string; + password?: string; + token?: string; + key?: string; + value?: string; + placement?: string; +} + +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(); @@ -155,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); @@ -181,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; } - // A header the request already carries (from the Headers tab or a pre-request script) wins over - // the Auth tab, as on desktop where the script runs after auth is applied and overwrites it. - 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 && !hasHeader(headers, 'Authorization')) { + 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 && !hasHeader(headers, 'Authorization')) { - headers['Authorization'] = `Bearer ${auth.token}`; + if (auth.token) { + writeUnlessScriptSet('Authorization', `Bearer ${auth.token}`); } break; case 'apikey': - if (auth.key && auth.value && auth.placement === 'header' && !hasHeader(headers, auth.key)) { - 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..ba584ac5 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 { enabledHeaderSnapshot, headerNamesWrittenSince } 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'; @@ -263,6 +264,7 @@ export class RequestRunner { // Pre-request script if (scriptsObj.preRequest) { + const headersBeforeScript = enabledHeaderSnapshot(processedRequest); try { await this.scriptRuntime.runScript({ script: scriptsObj.preRequest, @@ -281,6 +283,7 @@ export class RequestRunner { warnings: warnings.length ? warnings : null }; } + processedRequest.__brunoHeadersSetByScript = headerNamesWrittenSince(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 a79aa860..974500ae 100644 --- a/packages/bruno-api-docs/src/runner/req-mutations.spec.ts +++ b/packages/bruno-api-docs/src/runner/req-mutations.spec.ts @@ -312,7 +312,7 @@ items: expect(sent.fetchCalls).toBe(1); }); - it('a pre-request script that sets Authorization wins over folder-level inherited basic auth', async () => { + it('folder-level inherited basic auth overwrites a pre-request script Authorization header, as on desktop', async () => { const yaml = ` opencollection: "1.0.0" info: @@ -339,7 +339,7 @@ items: req.setHeader('AUTHORIZATION', 'Bearer script-token'); `; const sent = await sendWith(yaml, [0, 0]); - expect(sent.headers?.get('authorization')).toBe('Bearer script-token'); + expect(sent.headers?.get('authorization')).toBe(`Basic ${btoa('user:pass')}`); }); it('a pre-request script that deletes the Authorization header lets the configured auth apply', async () => { @@ -369,11 +369,11 @@ items: expect(sent.headers?.get('authorization')).toBe('Bearer config-token'); }); - it('a pre-request script that sets Authorization to an empty string lets the configured auth apply', async () => { + it('a pre-request script that clears Authorization to an empty string sends no Authorization header', async () => { const yaml = ` opencollection: "1.0.0" info: - name: "Empty Header Falls Back To Auth" + name: "Cleared Header Suppresses Auth" items: - name: "r" type: "http" @@ -390,7 +390,7 @@ items: req.setHeader('Authorization', ''); `; const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer config-token'); + expect(sent.headers?.has('authorization')).toBe(false); }); it('a pre-request script header value is interpolated before it is compared with the configured auth', async () => { @@ -421,11 +421,11 @@ items: expect(sent.headers?.get('authorization')).toBe('Bearer resolved-token'); }); - it('a collection-level Authorization header wins over the request bearer auth', async () => { + it('a collection-level Authorization header is overwritten by the request bearer auth, as on desktop', async () => { const yaml = ` opencollection: "1.0.0" info: - name: "Inherited Header Beats Request Auth" + name: "Request Auth Beats Inherited Header" request: headers: - name: "Authorization" @@ -441,7 +441,62 @@ items: token: "config-token" `; const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer collection-header-token'); + expect(sent.headers?.get('authorization')).toBe('Bearer config-token'); + }); + + 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 script header named like the api key still leaves the query-placement api key on the url', async () => { 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..e089c0c4 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/utils/script-headers.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import type { HttpRequest } from '@opencollection/types/requests/http'; +import { enabledHeaderSnapshot, headerNamesWrittenSince } from './script-headers'; + +const requestWith = (headers: { name: string; value: string; disabled?: boolean }[]) => ({ + name: 'r', + type: 'http', + http: { method: 'GET', url: 'https://api.example.com', headers } +} as unknown as HttpRequest); + +describe('enabledHeaderSnapshot', () => { + it('records enabled headers by lower-cased name', () => { + const snapshot = enabledHeaderSnapshot(requestWith([ + { name: 'Authorization', value: 'Bearer a' }, + { name: 'X-Off', value: 'no', disabled: true } + ])); + + expect([...snapshot.entries()]).toEqual([['authorization', 'Bearer a']]); + }); + + it('is empty for a request without headers', () => { + expect(enabledHeaderSnapshot(requestWith([])).size).toBe(0); + }); +}); + +describe('headerNamesWrittenSince', () => { + it('returns headers the script added', () => { + const before = enabledHeaderSnapshot(requestWith([{ name: 'Accept', value: 'json' }])); + const after = requestWith([{ name: 'Accept', value: 'json' }, { name: 'Authorization', value: 'Bearer s' }]); + + expect(headerNamesWrittenSince(before, after)).toEqual(['authorization']); + }); + + it('returns headers whose value the script changed', () => { + const before = enabledHeaderSnapshot(requestWith([{ name: 'Authorization', value: 'Bearer tab' }])); + const after = requestWith([{ name: 'Authorization', value: 'Bearer script' }]); + + expect(headerNamesWrittenSince(before, after)).toEqual(['authorization']); + }); + + it('matches a re-cased header against the snapshot without reporting it', () => { + const before = enabledHeaderSnapshot(requestWith([{ name: 'Authorization', value: 'Bearer tab' }])); + const after = requestWith([{ name: 'authorization', value: 'Bearer tab' }]); + + expect(headerNamesWrittenSince(before, after)).toEqual([]); + }); + + it('ignores headers the script left untouched, removed, or disabled', () => { + const before = enabledHeaderSnapshot(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 }]); + + expect(headerNamesWrittenSince(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..e7e5af23 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/utils/script-headers.ts @@ -0,0 +1,24 @@ +import type { HttpRequest } from '@opencollection/types/requests/http'; +import { getHttpHeaders } from '@/utils/schemaHelpers'; + +export type HeaderSnapshot = Map; + +export const enabledHeaderSnapshot = (request: HttpRequest): HeaderSnapshot => { + const snapshot: HeaderSnapshot = new Map(); + getHttpHeaders(request).forEach((header) => { + if (!header.disabled && header.name) { + snapshot.set(header.name.toLowerCase(), header.value); + } + }); + return snapshot; +}; + +export const headerNamesWrittenSince = (before: HeaderSnapshot, request: HttpRequest): string[] => { + const written = new Set(); + getHttpHeaders(request).forEach((header) => { + if (header.disabled || !header.name) return; + const name = header.name.toLowerCase(); + if (before.get(name) !== header.value) written.add(name); + }); + return [...written]; +}; 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'; }; From f5cdb884186565a71465a63e34d89f84ff445483 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 14 Sep 2026 21:36:02 +0530 Subject: [PATCH 03/11] refactor(playground): drop auth precedence tests that duplicate other cases Each remaining test maps to one acceptance criterion or one branch of the precedence code. Removed cases either repeated a covered branch at another layer or pinned behaviour this change does not own. --- .../src/runner/RequestExecutor.spec.ts | 20 -- .../src/runner/req-mutations.spec.ts | 205 ------------------ 2 files changed, 225 deletions(-) diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts index 97f71885..c74b0f57 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts @@ -501,15 +501,6 @@ describe('RequestExecutor auth header precedence', () => { 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' }, @@ -597,12 +588,6 @@ describe('RequestExecutor auth header precedence', () => { expect(headers.get('authorization')).toBe('Bearer config-token'); }); - it('still sends the configured api key header when no competing header exists', async () => { - const headers = await sentHeaders({ type: 'apikey', key: 'X-API-Key', value: 'config-key', placement: 'header' }); - - expect(headers.get('x-api-key')).toBe('config-key'); - }); - it('ignores a disabled Authorization header and sends the configured auth', async () => { const headers = await sentHeaders( { type: 'bearer', token: 'config-token' }, @@ -612,9 +597,4 @@ describe('RequestExecutor auth header precedence', () => { expect(headers.get('authorization')).toBe('Bearer config-token'); }); - it('sends no auth header when the request has no auth and no Authorization header', async () => { - const headers = await sentHeaders(undefined, [{ name: 'Accept', value: 'application/json', disabled: false }]); - - expect(headers.has('authorization')).toBe(false); - }); }); 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 974500ae..207819da 100644 --- a/packages/bruno-api-docs/src/runner/req-mutations.spec.ts +++ b/packages/bruno-api-docs/src/runner/req-mutations.spec.ts @@ -8,7 +8,6 @@ interface SentRequest { headers?: Headers; body?: unknown; timeoutArg?: number; - fetchCalls?: number; } const sendWith = async (yaml: string, itemPath: number[] = [0]): Promise => { @@ -39,7 +38,6 @@ const sendWith = async (yaml: string, itemPath: number[] = [0]): Promise 0 ? calls[calls.length - 1] : null; if (call) sent.timeoutArg = call[0]; - sent.fetchCalls = (global.fetch as ReturnType).mock.calls.length; timeoutSpy.mockRestore(); global.fetch = originalFetch; return sent; @@ -286,32 +284,6 @@ items: expect(sent.headers?.get('x-api-key')).toBe('script-key'); }); - it('a pre-request script that sets Authorization on a digest request is sent as-is with no challenge round trip', async () => { - const yaml = ` -opencollection: "1.0.0" -info: - name: "Script Digest Precedence" -items: - - name: "r" - type: "http" - http: - method: "GET" - url: "https://api.example.com/base" - auth: - type: "digest" - username: "user" - password: "pass" - 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'); - expect(sent.fetchCalls).toBe(1); - }); - it('folder-level inherited basic auth overwrites a pre-request script Authorization header, as on desktop', async () => { const yaml = ` opencollection: "1.0.0" @@ -342,108 +314,6 @@ items: expect(sent.headers?.get('authorization')).toBe(`Basic ${btoa('user:pass')}`); }); - it('a pre-request script that deletes the Authorization header lets the configured auth apply', async () => { - const yaml = ` -opencollection: "1.0.0" -info: - name: "Delete Header Restores Auth" -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.deleteHeader('authorization'); -`; - const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer config-token'); - }); - - it('a pre-request script that clears Authorization to an empty string sends no Authorization header', async () => { - const yaml = ` -opencollection: "1.0.0" -info: - name: "Cleared Header Suppresses Auth" -items: - - name: "r" - type: "http" - http: - method: "GET" - url: "https://api.example.com/base" - auth: - type: "bearer" - token: "config-token" - runtime: - scripts: - - type: before-request - code: | - req.setHeader('Authorization', ''); -`; - const sent = await sendWith(yaml); - expect(sent.headers?.has('authorization')).toBe(false); - }); - - it('a pre-request script header value is interpolated before it is compared with the configured auth', async () => { - const yaml = ` -opencollection: "1.0.0" -info: - name: "Interpolated Script Header" -request: - variables: - - name: "scriptToken" - value: "resolved-token" -items: - - name: "r" - type: "http" - http: - method: "GET" - url: "https://api.example.com/base" - auth: - type: "bearer" - token: "config-token" - runtime: - scripts: - - type: before-request - code: | - req.setHeader('Authorization', 'Bearer {{scriptToken}}'); -`; - const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer resolved-token'); - }); - - it('a collection-level Authorization header is overwritten by the request bearer auth, as on desktop', async () => { - const yaml = ` -opencollection: "1.0.0" -info: - name: "Request Auth Beats Inherited Header" -request: - headers: - - name: "Authorization" - value: "Bearer collection-header-token" -items: - - name: "r" - type: "http" - http: - method: "GET" - url: "https://api.example.com/base" - auth: - type: "bearer" - token: "config-token" -`; - const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer config-token'); - }); - 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" @@ -499,81 +369,6 @@ items: expect(sent.headers?.get('authorization')).toBe('Bearer script-token'); }); - it('a script header named like the api key still leaves the query-placement api key on the url', async () => { - const yaml = ` -opencollection: "1.0.0" -info: - name: "Query ApiKey Untouched" -items: - - name: "r" - type: "http" - http: - method: "GET" - url: "https://api.example.com/base" - auth: - type: "apikey" - key: "api_key" - value: "config-key" - placement: "query" - runtime: - scripts: - - type: before-request - code: | - req.setHeader('api_key', 'script-key'); -`; - const sent = await sendWith(yaml); - expect(sent.url).toBe('https://api.example.com/base?api_key=config-key'); - expect(sent.headers?.get('api_key')).toBe('script-key'); - }); - - it('req.setHeaders replacing all headers with an Authorization entry wins over the configured auth', async () => { - const yaml = ` -opencollection: "1.0.0" -info: - name: "Bulk Headers Precedence" -items: - - name: "r" - type: "http" - http: - method: "GET" - url: "https://api.example.com/base" - auth: - type: "bearer" - token: "config-token" - runtime: - scripts: - - type: before-request - code: | - req.setHeaders({ Authorization: 'Bearer bulk-token' }); -`; - const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer bulk-token'); - }); - - it('an incomplete bearer config writes nothing and leaves a script header untouched', async () => { - const yaml = ` -opencollection: "1.0.0" -info: - name: "Incomplete Auth" -items: - - name: "r" - type: "http" - http: - method: "GET" - url: "https://api.example.com/base" - auth: - type: "bearer" - 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 () => { const yaml = ` opencollection: "1.0.0" From 1294c45d047b77db2c43c39ad06a2a8d82503cb4 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 14 Sep 2026 21:38:42 +0530 Subject: [PATCH 04/11] style(playground): remove padding blank line left by the test trim --- packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts index c74b0f57..ab0868b9 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts @@ -596,5 +596,4 @@ describe('RequestExecutor auth header precedence', () => { expect(headers.get('authorization')).toBe('Bearer config-token'); }); - }); From 9641b9e2e42c55488e7333d1689ca6babe79cf8a Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 14 Sep 2026 22:04:50 +0530 Subject: [PATCH 05/11] fix(playground): runner owns the script header list and duplicate rows no longer skip auth The list of headers written by the pre-request script is now reset by the runner on every run, so a collection cannot pre-declare it and suppress the configured auth. The snapshot compares name and value pairs instead of a name keyed map, so two enabled rows with the same name are not mistaken for a script write. Helper names follow the verb-first convention of the folder. --- .../src/runner/RequestExecutor.ts | 10 ++-- packages/bruno-api-docs/src/runner/index.ts | 7 ++- .../src/runner/req-mutations.spec.ts | 23 +++++++ .../src/runner/utils/script-headers.spec.ts | 60 +++++++++++++------ .../src/runner/utils/script-headers.ts | 20 ++++--- 5 files changed, 85 insertions(+), 35 deletions(-) diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.ts index 410be0d0..165ef2cc 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.ts @@ -31,11 +31,6 @@ export const applyApiKeyToUrl = (url: string, auth: Record | un } }; -const hasHeader = (headers: Record, name: string): boolean => { - const lowerCaseName = name.toLowerCase(); - return Object.keys(headers).some((key) => key.toLowerCase() === lowerCaseName); -}; - interface HeaderAuthConfig { type?: string; username?: string; @@ -46,6 +41,11 @@ interface HeaderAuthConfig { 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) => { diff --git a/packages/bruno-api-docs/src/runner/index.ts b/packages/bruno-api-docs/src/runner/index.ts index ba584ac5..1ffcb167 100644 --- a/packages/bruno-api-docs/src/runner/index.ts +++ b/packages/bruno-api-docs/src/runner/index.ts @@ -8,7 +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 { enabledHeaderSnapshot, headerNamesWrittenSince } from './utils/script-headers'; +import { snapshotEnabledHeaders, getHeaderNamesWrittenSince } 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'; @@ -242,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); @@ -264,7 +265,7 @@ export class RequestRunner { // Pre-request script if (scriptsObj.preRequest) { - const headersBeforeScript = enabledHeaderSnapshot(processedRequest); + const headersBeforeScript = snapshotEnabledHeaders(processedRequest); try { await this.scriptRuntime.runScript({ script: scriptsObj.preRequest, @@ -283,7 +284,7 @@ export class RequestRunner { warnings: warnings.length ? warnings : null }; } - processedRequest.__brunoHeadersSetByScript = headerNamesWrittenSince(headersBeforeScript, processedRequest); + processedRequest.__brunoHeadersSetByScript = getHeaderNamesWrittenSince(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 207819da..59d2a127 100644 --- a/packages/bruno-api-docs/src/runner/req-mutations.spec.ts +++ b/packages/bruno-api-docs/src/runner/req-mutations.spec.ts @@ -369,6 +369,29 @@ items: 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('editing an inherited header in a pre-request script stays request-local and does not corrupt the shared collection config', async () => { const yaml = ` opencollection: "1.0.0" 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 index e089c0c4..c618f4f0 100644 --- a/packages/bruno-api-docs/src/runner/utils/script-headers.spec.ts +++ b/packages/bruno-api-docs/src/runner/utils/script-headers.spec.ts @@ -1,58 +1,82 @@ import { describe, it, expect } from 'vitest'; import type { HttpRequest } from '@opencollection/types/requests/http'; -import { enabledHeaderSnapshot, headerNamesWrittenSince } from './script-headers'; +import { snapshotEnabledHeaders, getHeaderNamesWrittenSince } from './script-headers'; -const requestWith = (headers: { name: string; value: string; disabled?: boolean }[]) => ({ +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('enabledHeaderSnapshot', () => { - it('records enabled headers by lower-cased name', () => { - const snapshot = enabledHeaderSnapshot(requestWith([ +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.entries()]).toEqual([['authorization', 'Bearer a']]); + 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(enabledHeaderSnapshot(requestWith([])).size).toBe(0); + expect(snapshotEnabledHeaders(requestWith([])).size).toBe(0); }); }); -describe('headerNamesWrittenSince', () => { +describe('getHeaderNamesWrittenSince', () => { it('returns headers the script added', () => { - const before = enabledHeaderSnapshot(requestWith([{ name: 'Accept', value: 'json' }])); + const before = snapshotEnabledHeaders(requestWith([{ name: 'Accept', value: 'json' }])); const after = requestWith([{ name: 'Accept', value: 'json' }, { name: 'Authorization', value: 'Bearer s' }]); - expect(headerNamesWrittenSince(before, after)).toEqual(['authorization']); + expect(getHeaderNamesWrittenSince(before, after)).toEqual(['authorization']); }); it('returns headers whose value the script changed', () => { - const before = enabledHeaderSnapshot(requestWith([{ name: 'Authorization', value: 'Bearer tab' }])); + const before = snapshotEnabledHeaders(requestWith([{ name: 'Authorization', value: 'Bearer tab' }])); const after = requestWith([{ name: 'Authorization', value: 'Bearer script' }]); - expect(headerNamesWrittenSince(before, after)).toEqual(['authorization']); + expect(getHeaderNamesWrittenSince(before, after)).toEqual(['authorization']); }); it('matches a re-cased header against the snapshot without reporting it', () => { - const before = enabledHeaderSnapshot(requestWith([{ name: 'Authorization', value: 'Bearer tab' }])); + const before = snapshotEnabledHeaders(requestWith([{ name: 'Authorization', value: 'Bearer tab' }])); const after = requestWith([{ name: 'authorization', value: 'Bearer tab' }]); - expect(headerNamesWrittenSince(before, after)).toEqual([]); + expect(getHeaderNamesWrittenSince(before, after)).toEqual([]); }); - it('ignores headers the script left untouched, removed, or disabled', () => { - const before = enabledHeaderSnapshot(requestWith([ + 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(getHeaderNamesWrittenSince(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 }]); + const after = requestWith([ + { name: 'Accept', value: 'json' }, + { name: 'X-Off', value: '2', disabled: true }, + { name: '', value: 'unnamed' } + ]); - expect(headerNamesWrittenSince(before, after)).toEqual([]); + expect(getHeaderNamesWrittenSince(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 index e7e5af23..7d8dc1d9 100644 --- a/packages/bruno-api-docs/src/runner/utils/script-headers.ts +++ b/packages/bruno-api-docs/src/runner/utils/script-headers.ts @@ -1,24 +1,26 @@ import type { HttpRequest } from '@opencollection/types/requests/http'; import { getHttpHeaders } from '@/utils/schemaHelpers'; -export type HeaderSnapshot = Map; +export type HeaderSnapshot = Set; -export const enabledHeaderSnapshot = (request: HttpRequest): HeaderSnapshot => { - const snapshot: HeaderSnapshot = new Map(); +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.set(header.name.toLowerCase(), header.value); - } + if (!header.disabled && header.name) snapshot.add(headerPairKey(header.name, header.value)); }); return snapshot; }; -export const headerNamesWrittenSince = (before: HeaderSnapshot, request: HttpRequest): string[] => { +// Lower-cased names of the enabled headers whose name and value pair is not in the snapshot: the +// ones a script added or gave a new value. Pairs, not names, so duplicate rows that all existed +// before the script do not count as written. +export const getHeaderNamesWrittenSince = (before: HeaderSnapshot, request: HttpRequest): string[] => { const written = new Set(); getHttpHeaders(request).forEach((header) => { if (header.disabled || !header.name) return; - const name = header.name.toLowerCase(); - if (before.get(name) !== header.value) written.add(name); + if (!before.has(headerPairKey(header.name, header.value))) written.add(header.name.toLowerCase()); }); return [...written]; }; From 7674a6cdaa6398bb508fb1b333f3c0aa6a32f09a Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 14 Sep 2026 22:06:15 +0530 Subject: [PATCH 06/11] test(playground): cover api key auth end to end and tidy the precedence specs Adds the missing e2e for a script header beating api key auth, restores the basic auth over Headers tab case, folds a casing duplicate into its sibling, types the test header rows, and moves the users request wait onto the response pane component so specs stop repeating the URL glob. --- .../playground/response-pane.component.ts | 5 +++ .../playground/script-auth-precedence.spec.ts | 35 +++++++++++++------ .../src/runner/RequestExecutor.spec.ts | 32 ++++++++++------- 3 files changed, 48 insertions(+), 24 deletions(-) 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 index df0a6405..2af2c7d7 100644 --- 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 @@ -9,6 +9,7 @@ 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(); @@ -40,18 +41,17 @@ test.describe('auth header precedence between the Headers tab, the Auth tab and await playground.selectTab('scripts'); await setEditorScript(page, playground.preRequestScriptEditor, SET_AUTHORIZATION_SCRIPT); - const sent = page.waitForRequest('**/api/users**'); + const sent = responsePane.waitForUsersRequest(); await responsePane.send(); const request = await sent; expect(request.headers()['authorization']).toBe(SCRIPT_AUTHORIZATION); - await expect(responsePane.status).toContainText('200'); }); - test('an Authorization row in the Headers tab is overwritten by the configured bearer token, as on desktop', async ({ page, playground, responsePane }) => { + 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 = page.waitForRequest('**/api/users**'); + const sent = responsePane.waitForUsersRequest(); await responsePane.send(); const request = await sent; @@ -63,7 +63,7 @@ test.describe('auth header precedence between the Headers tab, the Auth tab and await playground.selectTab('scripts'); await setEditorScript(page, playground.preRequestScriptEditor, SET_AUTHORIZATION_SCRIPT); - const sent = page.waitForRequest('**/api/users**'); + const sent = responsePane.waitForUsersRequest(); await responsePane.send(); const request = await sent; @@ -77,30 +77,43 @@ test.describe('auth header precedence between the Headers tab, the Auth tab and await playground.selectTab('scripts'); await setEditorScript(page, playground.preRequestScriptEditor, SET_AUTHORIZATION_SCRIPT); - const sent = page.waitForRequest('**/api/users**'); + 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 No Auth selected, the Headers tab Authorization row is sent as typed', async ({ page, playground, responsePane }) => { + 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 = page.waitForRequest('**/api/users**'); + 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 ({ page, responsePane }) => { - const sent = page.waitForRequest('**/api/users**'); + 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}`); - await expect(responsePane.status).toContainText('200'); }); }); diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts index ab0868b9..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', () => { @@ -454,6 +455,12 @@ describe('RequestExecutor digest auth', () => { }); }); +interface HeaderRow { + name: string; + value: string; + disabled?: boolean; +} + describe('RequestExecutor auth header precedence', () => { const originalFetch = global.fetch; @@ -473,7 +480,7 @@ describe('RequestExecutor auth header precedence', () => { const sentHeaders = async ( auth: Record | undefined, - headers: unknown[] = [], + headers: HeaderRow[] = [], headersSetByScript: string[] = [] ) => { const fetchMock = vi.fn().mockResolvedValue(okResponse()); @@ -484,7 +491,7 @@ describe('RequestExecutor auth header precedence', () => { type: 'http', http: { method: 'GET', url: 'https://api.example.com/data', auth, headers }, __brunoHeadersSetByScript: headersSetByScript - } as unknown as HttpRequest); + } as unknown as InternalHttpRequest); return new Headers(fetchMock.mock.calls[0][1].headers as Record); }; @@ -501,6 +508,15 @@ describe('RequestExecutor auth header precedence', () => { 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' }, @@ -531,6 +547,7 @@ describe('RequestExecutor auth header precedence', () => { ); 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 () => { @@ -543,17 +560,6 @@ describe('RequestExecutor auth header precedence', () => { expect(headers.get('authorization')).toBe(`Basic ${btoa('user:pass')}`); }); - it('in another casing wins over configured bearer auth without duplication', 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('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' }, From 023276bf12cd1b2419a59b1adb142230bbe10b0e Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Tue, 15 Sep 2026 16:12:33 +0530 Subject: [PATCH 07/11] refactor(playground): name the script header diff after what it returns --- packages/bruno-api-docs/src/runner/index.ts | 7 +++++-- .../src/runner/utils/script-headers.spec.ts | 14 +++++++------- .../src/runner/utils/script-headers.ts | 14 +++++++------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/bruno-api-docs/src/runner/index.ts b/packages/bruno-api-docs/src/runner/index.ts index 1ffcb167..52920a85 100644 --- a/packages/bruno-api-docs/src/runner/index.ts +++ b/packages/bruno-api-docs/src/runner/index.ts @@ -8,7 +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, getHeaderNamesWrittenSince } from './utils/script-headers'; +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'; @@ -284,7 +284,10 @@ export class RequestRunner { warnings: warnings.length ? warnings : null }; } - processedRequest.__brunoHeadersSetByScript = getHeaderNamesWrittenSince(headersBeforeScript, processedRequest); + processedRequest.__brunoHeadersSetByScript = getHeaderNamesChangedByScript( + headersBeforeScript, + processedRequest + ); } const interpolatedRequest = interpolateVars(processedRequest, allVariables); 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 index c618f4f0..d035bdf1 100644 --- a/packages/bruno-api-docs/src/runner/utils/script-headers.spec.ts +++ b/packages/bruno-api-docs/src/runner/utils/script-headers.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { HttpRequest } from '@opencollection/types/requests/http'; -import { snapshotEnabledHeaders, getHeaderNamesWrittenSince } from './script-headers'; +import { snapshotEnabledHeaders, getHeaderNamesChangedByScript } from './script-headers'; interface HeaderRow { name: string; @@ -33,26 +33,26 @@ describe('snapshotEnabledHeaders', () => { }); }); -describe('getHeaderNamesWrittenSince', () => { +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(getHeaderNamesWrittenSince(before, after)).toEqual(['authorization']); + 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(getHeaderNamesWrittenSince(before, after)).toEqual(['authorization']); + 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(getHeaderNamesWrittenSince(before, after)).toEqual([]); + expect(getHeaderNamesChangedByScript(before, after)).toEqual([]); }); it('does not report duplicate same-name rows that were all present before the script', () => { @@ -62,7 +62,7 @@ describe('getHeaderNamesWrittenSince', () => { ]; const before = snapshotEnabledHeaders(requestWith(rows)); - expect(getHeaderNamesWrittenSince(before, requestWith(rows))).toEqual([]); + expect(getHeaderNamesChangedByScript(before, requestWith(rows))).toEqual([]); }); it('ignores headers the script left untouched, removed, disabled, or left unnamed', () => { @@ -77,6 +77,6 @@ describe('getHeaderNamesWrittenSince', () => { { name: '', value: 'unnamed' } ]); - expect(getHeaderNamesWrittenSince(before, after)).toEqual([]); + 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 index 7d8dc1d9..051ae630 100644 --- a/packages/bruno-api-docs/src/runner/utils/script-headers.ts +++ b/packages/bruno-api-docs/src/runner/utils/script-headers.ts @@ -13,14 +13,14 @@ export const snapshotEnabledHeaders = (request: HttpRequest): HeaderSnapshot => return snapshot; }; -// Lower-cased names of the enabled headers whose name and value pair is not in the snapshot: the -// ones a script added or gave a new value. Pairs, not names, so duplicate rows that all existed -// before the script do not count as written. -export const getHeaderNamesWrittenSince = (before: HeaderSnapshot, request: HttpRequest): string[] => { - const written = new Set(); +// 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))) written.add(header.name.toLowerCase()); + if (!before.has(headerPairKey(header.name, header.value))) changed.add(header.name.toLowerCase()); }); - return [...written]; + return [...changed]; }; From 1cc39a382874985fa7c48865f5a9427d1dec33a9 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Tue, 15 Sep 2026 16:12:36 +0530 Subject: [PATCH 08/11] fix(playground): a script header write leaves one row per name req.setHeader and req.headerList.upsert updated only the first enabled row with a matching name, while the executor sends the last row. With duplicate Headers tab rows the script value never reached the wire, and once the script owned the name the Auth tab was skipped too, so neither value went out. Both writes now drop the other enabled duplicates, matching the single header object on desktop. --- .../src/runner/req-mutations.spec.ts | 55 +++++++++++++++++++ .../src/scripting/utils/bruno-request.ts | 5 ++ .../src/scripting/utils/header-list.spec.ts | 15 +++++ .../src/scripting/utils/header-list.ts | 3 + 4 files changed, 78 insertions(+) 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 59d2a127..72cda877 100644 --- a/packages/bruno-api-docs/src/runner/req-mutations.spec.ts +++ b/packages/bruno-api-docs/src/runner/req-mutations.spec.ts @@ -392,6 +392,61 @@ items: 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 () => { const yaml = ` opencollection: "1.0.0" 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..00af05f4 100644 --- a/packages/bruno-api-docs/src/scripting/utils/bruno-request.ts +++ b/packages/bruno-api-docs/src/scripting/utils/bruno-request.ts @@ -140,11 +140,16 @@ class BrunoRequest { return header ? header.value : undefined; } + // One enabled row per name after a script write, like the header object on desktop: the first + // matching row takes the value and any other enabled duplicates are dropped. 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 ?? ''); + for (let i = list.length - 1; i >= 0; i--) { + if (list[i] !== existing && !list[i].disabled && sameName(list[i].name, name)) list.splice(i, 1); + } } else { list.push({ name, value: String(value ?? '') }); } 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..09eb0eec 100644 --- a/packages/bruno-api-docs/src/scripting/utils/header-list.ts +++ b/packages/bruno-api-docs/src/scripting/utils/header-list.ts @@ -157,6 +157,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 ?? '') }); From 9fb865593cb70a033732006066fb2e18f63a686f Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Tue, 15 Sep 2026 16:34:31 +0530 Subject: [PATCH 09/11] refactor(playground): req.setHeader reuses headerList.upsert Both wrote a header the same way, so setHeader now calls upsert and the duplicate row handling lives in one place. --- .../src/scripting/utils/bruno-request.ts | 13 +------------ .../src/scripting/utils/header-list.ts | 3 +++ 2 files changed, 4 insertions(+), 12 deletions(-) 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 00af05f4..49835b55 100644 --- a/packages/bruno-api-docs/src/scripting/utils/bruno-request.ts +++ b/packages/bruno-api-docs/src/scripting/utils/bruno-request.ts @@ -140,19 +140,8 @@ class BrunoRequest { return header ? header.value : undefined; } - // One enabled row per name after a script write, like the header object on desktop: the first - // matching row takes the value and any other enabled duplicates are dropped. 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 ?? ''); - for (let i = list.length - 1; i >= 0; i--) { - if (list[i] !== existing && !list[i].disabled && sameName(list[i].name, name)) list.splice(i, 1); - } - } 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.ts b/packages/bruno-api-docs/src/scripting/utils/header-list.ts index 09eb0eec..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; From 46611493934d42563fdd1183bc79ca6d29f58e6e Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 21 Sep 2026 23:00:10 +0530 Subject: [PATCH 10/11] test(playground): assert on sent headers without optional chaining --- .../src/runner/req-mutations.spec.ts | 74 +++++++++---------- 1 file changed, 36 insertions(+), 38 deletions(-) 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 72cda877..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,7 @@ 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 () => { @@ -255,7 +255,7 @@ items: req.setHeader('authorization', 'Bearer script-token'); `; const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer script-token'); + 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 () => { @@ -281,7 +281,7 @@ items: req.setHeader('X-API-Key', 'script-key'); `; const sent = await sendWith(yaml); - expect(sent.headers?.get('x-api-key')).toBe('script-key'); + 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 () => { @@ -311,7 +311,7 @@ items: req.setHeader('AUTHORIZATION', 'Bearer script-token'); `; const sent = await sendWith(yaml, [0, 0]); - expect(sent.headers?.get('authorization')).toBe(`Basic ${btoa('user:pass')}`); + 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 () => { @@ -338,8 +338,8 @@ items: 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'); + 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 () => { @@ -366,7 +366,7 @@ items: req.setHeader('Authorization', 'Bearer script-token'); `; const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer script-token'); + expect(sent.headers.get('authorization')).toBe('Bearer script-token'); }); it('a collection cannot pre-declare script-written headers to suppress the configured auth', async () => { @@ -389,7 +389,7 @@ items: token: "config-token" `; const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer config-token'); + 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 () => { @@ -415,7 +415,7 @@ items: req.setHeader('X-Dup', 'script'); `; const sent = await sendWith(yaml); - expect(sent.headers?.get('x-dup')).toBe('script'); + expect(sent.headers.get('x-dup')).toBe('script'); }); it('a pre-request script overwriting duplicate Authorization rows wins over the configured bearer auth', async () => { @@ -444,7 +444,7 @@ items: req.setHeader('Authorization', 'Bearer script-token'); `; const sent = await sendWith(yaml); - expect(sent.headers?.get('authorization')).toBe('Bearer script-token'); + 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 () => { @@ -469,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({ @@ -490,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' }]); }); }); From bd7228c79588a58dd77c97aef3650aa0b418541a Mon Sep 17 00:00:00 2001 From: Arpit Date: Tue, 22 Sep 2026 12:22:03 +0530 Subject: [PATCH 11/11] Fix header precedence in playground requests --- .changeset/wicked-points-send.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wicked-points-send.md 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