From 985b1035850d9d0f373414a1d92e6cd6f7530728 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Thu, 23 Jul 2026 13:29:04 +0530 Subject: [PATCH 1/8] Inherit auth changes from folder, collection in request --- .../components/playground/auth.component.ts | 25 ++++++ .../oc-docs/e2e/playwright/pages.fixture.ts | 5 ++ .../tests/playground/playground-auth.spec.ts | 48 ++++++++++ .../Views/Common/AuthTab/AuthTab.spec.tsx | 2 +- .../Content/Views/Common/AuthTab/AuthTab.tsx | 6 +- .../RequestPane/RequestPane.tsx | 1 + .../src/runner/utils/request-merger.spec.ts | 89 +++++++++++++++++++ .../src/runner/utils/request-merger.ts | 62 +++++++------ 8 files changed, 208 insertions(+), 30 deletions(-) create mode 100644 packages/oc-docs/e2e/components/playground/auth.component.ts create mode 100644 packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts create mode 100644 packages/oc-docs/src/runner/utils/request-merger.spec.ts diff --git a/packages/oc-docs/e2e/components/playground/auth.component.ts b/packages/oc-docs/e2e/components/playground/auth.component.ts new file mode 100644 index 00000000..147a3819 --- /dev/null +++ b/packages/oc-docs/e2e/components/playground/auth.component.ts @@ -0,0 +1,25 @@ +import type { Locator } from '@playwright/test'; +import { BaseComponent } from '../base.component'; + +// The Auth tab inside the playground request pane: the auth-mode dropdown, its options, +// and the notice shown when a request inherits its auth. Open the tab via +// playground.selectTab('auth'). +export class RequestAuthComponent extends BaseComponent { + readonly modeSelect = this.page.getByTestId('auth-mode-select'); + readonly inheritNotice = this.page.getByTestId('auth-inherit-notice'); + // Options render as role="option" inside the (listbox) dropdown once it is open. + readonly options = this.page.getByRole('option'); + + option(value: string): Locator { + return this.page.getByTestId(`auth-mode-select-${value}`); + } + + async open(): Promise { + await this.modeSelect.click(); + } + + async selectMode(value: string): Promise { + await this.modeSelect.click(); + await this.option(value).click(); + } +} diff --git a/packages/oc-docs/e2e/playwright/pages.fixture.ts b/packages/oc-docs/e2e/playwright/pages.fixture.ts index c831dda1..3329fec8 100644 --- a/packages/oc-docs/e2e/playwright/pages.fixture.ts +++ b/packages/oc-docs/e2e/playwright/pages.fixture.ts @@ -9,6 +9,7 @@ import { SidebarComponent } from '../components/sidebar.component'; import { TooltipComponent } from '../components/tooltip.component'; import { PlaygroundComponent } from '../components/playground.component'; import { RequestBodyComponent } from '../components/playground/request-body.component'; +import { RequestAuthComponent } from '../components/playground/auth.component'; import { EnvEditorComponent } from '../components/environments/env-editor.component'; import { CollectionSettingsComponent } from '../components/collection-settings/collection-settings.component'; import { ThemeToggleComponent } from '../components/layout/theme-toggle.component'; @@ -27,6 +28,7 @@ type Fixtures = { tooltip: TooltipComponent; playground: PlaygroundComponent; requestBody: RequestBodyComponent; + requestAuth: RequestAuthComponent; envEditor: EnvEditorComponent; collectionSettings: CollectionSettingsComponent; pageHeader: PageHeaderComponent; @@ -72,6 +74,9 @@ export const test = base.extend({ requestBody: async ({ page }, use) => { await use(new RequestBodyComponent(page)); }, + requestAuth: async ({ page }, use) => { + await use(new RequestAuthComponent(page)); + }, envEditor: async ({ page }, use) => { await use(new EnvEditorComponent(page)); }, diff --git a/packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts b/packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts new file mode 100644 index 00000000..134d4e43 --- /dev/null +++ b/packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '../../playwright'; + +const DESKTOP = { width: 1280, height: 900 }; + +test.describe('Playground request — inherited auth', () => { + test.use({ viewport: DESKTOP }); + + test('the auth dropdown offers Inherit alongside the four supported types, and nothing more', async ({ + playground, + requestAuth + }) => { + await playground.open('bottom'); + await playground.openRequest('get users'); + await playground.selectTab('auth'); + + await requestAuth.open(); + await expect(requestAuth.option('none')).toBeVisible(); + await expect(requestAuth.option('inherit')).toBeVisible(); + await expect(requestAuth.option('basic')).toBeVisible(); + await expect(requestAuth.option('bearer')).toBeVisible(); + await expect(requestAuth.option('apikey')).toBeVisible(); + // No Auth + Inherit + Basic + Bearer + API Key — no other auth types are offered. + await expect(requestAuth.options).toHaveCount(5); + }); + + test('choosing Inherit shows the inherited-auth notice and marks the mode selected', async ({ + playground, + requestAuth + }) => { + await playground.open('bottom'); + await playground.openRequest('get users'); + await playground.selectTab('auth'); + + await requestAuth.selectMode('inherit'); + await expect(requestAuth.modeSelect).toContainText('Inherit'); + await expect(requestAuth.inheritNotice).toBeVisible(); + }); + + test('a request already set to inherit opens with Inherit preselected', async ({ playground, requestAuth }) => { + await playground.open('bottom'); + // Get All Customers inherits its auth from the Bearer-authed collection. + await playground.openTreeItem(['billing', 'customers', 'Get All Customers']); + await playground.selectTab('auth'); + + await expect(requestAuth.modeSelect).toContainText('Inherit'); + await expect(requestAuth.inheritNotice).toBeVisible(); + }); +}); diff --git a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.spec.tsx b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.spec.tsx index 0fa07417..f4103d4c 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.spec.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.spec.tsx @@ -53,7 +53,7 @@ describe('AuthTab', () => { it('shows an inherit note for inherited auth', () => { const { root } = renderAuth('inherit', { showInherit: true }); - expect(query(root, '.auth-empty').text.trim()).toBe('Inherits auth from parent collection.'); + expect(query(root, '.auth-empty').text.trim()).toBe('Inherits authentication from the parent folder or collection.'); }); it('renders basic auth with a plain username and a masked, revealable password', () => { diff --git a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx index f94abe52..ff8b1c4f 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx @@ -138,7 +138,11 @@ export const AuthTab: React.FC = ({ return

No authentication configured.

; } if (auth === 'inherit') { - return

Inherits auth from parent collection.

; + return ( +

+ Inherits authentication from the parent folder or collection. +

+ ); } if (showFullAuth) { return
{renderForm()}
; diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx index 4c937b00..9cec8209 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx @@ -177,6 +177,7 @@ const RequestPane: React.FC = ({ item, onItemChange }) => { item={item} title="" description="Configures authentication for this request." + showInherit={true} showFullAuth={true} /> ); diff --git a/packages/oc-docs/src/runner/utils/request-merger.spec.ts b/packages/oc-docs/src/runner/utils/request-merger.spec.ts new file mode 100644 index 00000000..e9601287 --- /dev/null +++ b/packages/oc-docs/src/runner/utils/request-merger.spec.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { mergeAuth } from './request-merger'; +import { getRequestAuth } from '../../utils/schemaHelpers'; + +// A playground request keeps its auth on the http protocol block; folders/collection nest +// it under `request.auth`. Auth is either a concrete object, the string 'inherit', or +// undefined (= No Auth). +const httpRequest = (auth: unknown): any => ({ http: { method: 'GET', url: 'https://x', auth } }); +const folder = (name: string, auth: unknown): any => ({ info: { type: 'folder', name }, request: { auth } }); +const collection = (auth: unknown): any => ({ info: { name: 'C' }, request: { auth } }); + +const BASIC = { type: 'basic', username: 'u', password: 'p' }; +const BEARER = { type: 'bearer', token: 't' }; +const APIKEY = { type: 'apikey', key: 'k', value: 'v', placement: 'header' }; + +// mergeAuth mutates the request; read back the effective auth the executor will use. +const resolve = (request: any, coll: any, path: any[] = []): unknown => { + mergeAuth(coll, request, path); + return getRequestAuth(request); +}; + +describe('mergeAuth (inherited-auth resolution for the playground send path)', () => { + describe('acceptance #1 — a request set to inherit uses its nearest parent', () => { + it('resolves from the collection when no folder configures auth', () => { + expect(resolve(httpRequest('inherit'), collection(BEARER))).toMatchObject(BEARER); + }); + + it('prefers the closest folder over the collection', () => { + const path = [folder('shallow', BASIC), folder('deep', APIKEY)]; // root -> leaf + expect(resolve(httpRequest('inherit'), collection(BEARER), path)).toMatchObject(APIKEY); + }); + }); + + describe('acceptance #2 — a folder set to inherit resolves from its own parent chain', () => { + it('is transparent and falls through to the collection', () => { + expect(resolve(httpRequest('inherit'), collection(BASIC), [folder('F', 'inherit')])).toMatchObject(BASIC); + }); + + it('is transparent and falls through to a shallower concrete folder (not skipping past it to the collection)', () => { + const path = [folder('outer', BEARER), folder('inner', 'inherit')]; + expect(resolve(httpRequest('inherit'), collection(BASIC), path)).toMatchObject(BEARER); + }); + }); + + describe('acceptance #3 — the resolved auth is applied for every supported mode', () => { + for (const mode of [BASIC, BEARER, { type: 'apikey', key: 'k', value: 'v', placement: 'query' }]) { + it(`applies inherited ${mode.type}`, () => { + expect(resolve(httpRequest('inherit'), collection(mode))).toMatchObject(mode); + }); + } + }); + + describe('acceptance #4 — an explicit No Auth is respected and never overridden by a parent', () => { + it('leaves a request with its own concrete auth untouched, ignoring the parent', () => { + const own = { type: 'bearer', token: 'own' }; + expect(resolve(httpRequest(own), collection(BASIC))).toMatchObject(own); + }); + + it('respects No Auth on the request itself against an authed collection', () => { + expect(resolve(httpRequest(undefined), collection(BEARER), [folder('F', BASIC)])).toBeUndefined(); + }); + + it('a folder set to No Auth blocks the collection auth for an inheriting request', () => { + expect(resolve(httpRequest('inherit'), collection(BEARER), [folder('F', undefined)])).toBeUndefined(); + }); + + it('the closest No-Auth level wins over a shallower concrete folder', () => { + const path = [folder('outer', BEARER), folder('inner', undefined)]; // inner = No Auth, blocks + expect(resolve(httpRequest('inherit'), collection(BASIC), path)).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('falls back to No Auth when an inherit chain configures nothing concrete', () => { + expect(resolve(httpRequest('inherit'), collection(undefined), [folder('F', 'inherit')])).toBeUndefined(); + }); + + it('does not alias the shared collection/folder auth object into the per-run request', () => { + const coll = collection(BEARER); + const request = httpRequest('inherit'); + mergeAuth(coll, request, []); + const applied = getRequestAuth(request) as any; + expect(applied).toMatchObject(BEARER); + expect(applied).not.toBe(coll.request.auth); // cloned, not the same reference + applied.token = 'mutated'; + expect(coll.request.auth.token).toBe('t'); // mutation does not leak back to the collection + }); + }); +}); diff --git a/packages/oc-docs/src/runner/utils/request-merger.ts b/packages/oc-docs/src/runner/utils/request-merger.ts index 1bdf9028..a130b62b 100644 --- a/packages/oc-docs/src/runner/utils/request-merger.ts +++ b/packages/oc-docs/src/runner/utils/request-merger.ts @@ -1,10 +1,9 @@ import type { OpenCollection } from '@opencollection/types'; import type { HttpRequest, HttpRequestHeader } from '@opencollection/types/requests/http'; import type { Item } from '@opencollection/types/collection/item'; -import type { Scripts, Script, ScriptType } from '@opencollection/types/common/scripts'; -import type { Auth } from '@opencollection/types/common/auth'; -import { - isFolder, +import type { Scripts } from '@opencollection/types/common/scripts'; +import { + isFolder, getHttpHeaders, getRequestAuth, getHttpMethod, @@ -12,11 +11,9 @@ import { getRequestScripts, scriptsArrayToObject, scriptsObjectToArray, - getPreRequestScript, - getPostResponseScript, - getTestsScript, type ScriptsObject } from '../../utils/schemaHelpers'; +import type { Auth } from '@opencollection/types/common/auth'; /** * Merge headers from collection and folder hierarchy into the request @@ -70,35 +67,44 @@ export const mergeHeaders = (collection: OpenCollection, request: HttpRequest, r }); }; +// A concrete auth is a configured mode (basic/bearer/apikey/…), as opposed to the +// `inherit` sentinel or an absent value (No Auth). +const isConcreteAuth = (auth: Auth | undefined): auth is Exclude => !!auth && auth !== 'inherit'; + /** - * Merge authentication from collection and folder hierarchy into the request + * Resolve inherited authentication into the request, matching the desktop app's send path + * (bruno-electron `mergeAuth`). Only an explicit `inherit` resolves; a request with its own + * concrete auth — or an explicit No Auth — is left untouched, so a parent never overrides an + * explicit choice (acceptance: an explicit No Auth is respected). + * + * Walking the chain outward-in, the closest level that made an explicit auth choice wins: a + * folder set to a concrete mode supplies it, a folder set to No Auth blocks a parent's auth, + * and a folder left on `inherit` is transparent. This intentionally differs from the display + * resolver (`utils/request.ts` resolveInheritedAuth, which — like the app's UI — skips + * No-Auth folders); the send path mirrors what the app actually puts on the wire. */ export const mergeAuth = (collection: OpenCollection, request: HttpRequest, requestTreePath: Item[] = []): void => { - const requestAuth = getRequestAuth(request); - - // If request already has auth that's not 'inherit', don't override - if (requestAuth && requestAuth !== 'inherit') { + if (getRequestAuth(request) !== 'inherit') { return; } - - // Auth lives on the protocol-detail block (request.http.auth); ensure it exists. - if (!request.http) { - request.http = {}; - } - // Look for auth in reverse order (closest to request wins) - for (let i = requestTreePath.length - 1; i >= 0; i--) { - const item = requestTreePath[i]; - if (isFolder(item) && item.request?.auth && item.request.auth !== 'inherit') { - request.http.auth = item.request.auth; - return; - } + // The collection is the base; folders nearer the request override it. + const collectionAuth = collection.request?.auth as Auth | undefined; + let effective: Exclude | undefined = isConcreteAuth(collectionAuth) ? collectionAuth : undefined; + + for (const item of requestTreePath) { + if (!isFolder(item)) continue; + const folderAuth = (item as { request?: { auth?: Auth } }).request?.auth; + if (folderAuth === 'inherit') continue; // transparent — keep looking outward-in + effective = isConcreteAuth(folderAuth) ? folderAuth : undefined; // concrete applies; No Auth blocks } - // Finally, check collection-level auth - if (collection.request?.auth && collection.request.auth !== 'inherit') { - request.http.auth = collection.request.auth; + // Auth lives on the protocol-detail block (request.http.auth); ensure it exists. + if (!request.http) { + request.http = {}; } + // Clone so the per-run request never aliases the shared collection/folder auth object. + request.http.auth = effective ? { ...effective } : undefined; }; /** @@ -185,7 +191,7 @@ export const mergeScripts = ( mergedScriptsObj.tests = testsParts.length > 0 ? testsParts.join('\n\n') : undefined; } else { // Sequential flow: collection -> folders -> request (each overrides previous) - let currentScripts = { ...collectionScriptsObj }; + const currentScripts = { ...collectionScriptsObj }; folderScriptsObjs.forEach((scripts) => { if (scripts.preRequest) currentScripts.preRequest = scripts.preRequest; From 2a88745be47cbc4843273688c93d94635cebbfdb Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 24 Jul 2026 17:34:44 +0530 Subject: [PATCH 2/8] Inherited auth changes and fixes --- .../Views/Common/AuthTab/AuthTab.spec.tsx | 13 +- .../Content/Views/Common/AuthTab/AuthTab.tsx | 18 ++- .../Views/Common/AuthTab/StyledWrapper.ts | 4 + .../FolderSettingsView/FolderSettingsView.tsx | 15 +- .../Views/PlaygroundView/PlaygroundView.tsx | 7 +- .../RequestPane/RequestPane.tsx | 6 +- packages/oc-docs/src/runner/index.spec.ts | 143 ++++++++++++++++++ .../src/runner/utils/request-merger.spec.ts | 32 ++++ .../src/runner/utils/request-merger.ts | 19 ++- .../src/runner/utils/tree-utils.spec.ts | 35 +++++ .../oc-docs/src/runner/utils/tree-utils.ts | 18 ++- packages/oc-docs/src/utils/request.spec.ts | 35 +++++ packages/oc-docs/src/utils/request.ts | 41 ++++- 13 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 packages/oc-docs/src/runner/utils/tree-utils.spec.ts diff --git a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.spec.tsx b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.spec.tsx index f4103d4c..1d341b2c 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.spec.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.spec.tsx @@ -51,9 +51,18 @@ describe('AuthTab', () => { expect(query(root, '.auth-empty').text.trim()).toBe('No authentication configured.'); }); - it('shows an inherit note for inherited auth', () => { + it('names the inherited source and its effective mode, matching the app', () => { + const { root, getByTestId } = renderAuth('inherit', { + showInherit: true, + inheritedAuth: { sourceName: 'Users', modeLabel: 'Bearer Token' } + }); + expect(query(root, '.auth-empty').text.replace(/\s+/g, ' ').trim()).toBe('Auth inherited from Users: Bearer Token'); + expect(getByTestId('inherited-auth-mode').text.trim()).toBe('Bearer Token'); + }); + + it('falls back to a generic inherit note when the source is not resolved', () => { const { root } = renderAuth('inherit', { showInherit: true }); - expect(query(root, '.auth-empty').text.trim()).toBe('Inherits authentication from the parent folder or collection.'); + expect(query(root, '.auth-empty').text.trim()).toBe('Auth inherited from the parent folder or collection.'); }); it('renders basic auth with a plain username and a masked, revealable password', () => { diff --git a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx index ff8b1c4f..765a009d 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import { Field, type SelectOption } from '../../../../../../ui/Field'; import { AUTH_DEFAULTS, AUTH_MODE_LABELS, PLACEMENT_OPTIONS } from '../../../../../../constants'; import { REQUEST_PROTOCOL_KEYS } from '../../../../../../utils/schemaHelpers'; +import type { InheritedAuthSummary } from '../../../../../../utils/request'; import { StyledWrapper } from './StyledWrapper'; interface AuthTabProps { @@ -13,6 +14,7 @@ interface AuthTabProps { description?: string; showInherit?: boolean; showFullAuth?: boolean; + inheritedAuth?: InheritedAuthSummary; } export const AuthTab: React.FC = ({ @@ -23,7 +25,8 @@ export const AuthTab: React.FC = ({ title = 'Authentication', description, showInherit = false, - showFullAuth = false + showFullAuth = false, + inheritedAuth }) => { const authType = typeof auth === 'object' && auth !== null ? auth.type : auth === 'inherit' ? 'inherit' : 'none'; @@ -140,7 +143,16 @@ export const AuthTab: React.FC = ({ if (auth === 'inherit') { return (

- Inherits authentication from the parent folder or collection. + {inheritedAuth ? ( + + Auth inherited from {inheritedAuth.sourceName}:{' '} + + {inheritedAuth.modeLabel} + + + ) : ( + 'Auth inherited from the parent folder or collection.' + )}

); } diff --git a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/StyledWrapper.ts b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/StyledWrapper.ts index 48f7a6f8..42f00f02 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/StyledWrapper.ts @@ -29,6 +29,10 @@ export const StyledWrapper = styled.div` color: var(--text-muted); } + .auth-inherited-mode { + color: var(--primary-text); + } + .auth-form { display: flex; flex-direction: column; diff --git a/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx b/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx index ff74cff8..982d7d0b 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import type { Folder } from '@opencollection/types/collection/item'; import type { OpenCollection } from '@opencollection/types'; +import type { HttpRequest } from '@opencollection/types/requests/http'; import Tabs from '../../../../../ui/Tabs/Tabs'; import { type KeyValueRow } from '../../../../../components/KeyValueTable/KeyValueTable'; import { rowToVariable } from '../../../../../utils/variableDataType'; @@ -17,6 +18,9 @@ import { scriptsArrayToObject, scriptsObjectToArray } from '../../../../../utils/schemaHelpers'; +import { getAncestorsByUuid } from '../../../../../utils/fileUtils'; +import { getItemUuid } from '../../../../../utils/itemUtils'; +import { getInheritedAuthSummary } from '../../../../../utils/request'; import TestsTab from '../Common/TestsTab/TestsTab'; import OverviewTab from '../Common/OverviewTab/OverviewTab'; @@ -26,10 +30,16 @@ interface FolderSettingsProps { onFolderChange: (updatedFolder: Folder) => void; } -const FolderSettings: React.FC = ({ folder, onFolderChange }) => { +const FolderSettings: React.FC = ({ folder, collection, onFolderChange }) => { const dispatch = useAppDispatch(); const [activeTab, setActiveTab] = useState('overview'); + const inheritedAuth = useMemo(() => { + const uuid = getItemUuid(folder); + const ancestry = uuid ? getAncestorsByUuid(collection, uuid) : []; + return getInheritedAuthSummary(collection, ancestry, folder as unknown as HttpRequest); + }, [folder, collection]); + const handleHeadersChange = (headers: KeyValueRow[]) => { const originals = folder.request?.headers ?? []; const originalByName = new Map( @@ -162,6 +172,7 @@ const FolderSettings: React.FC = ({ folder, onFolderChange description="Configures authentication for this folder. This applies to all requests using the Inherit option in the Auth tab." showInherit={true} showFullAuth={true} + inheritedAuth={inheritedAuth} /> ); diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx index 7c7457d6..bf52642b 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx @@ -12,6 +12,7 @@ import ResponsePane from './ResponsePane/ResponsePane'; import { useAppDispatch, useAppSelector } from '../../../../../store/hooks'; import { updatePlaygroundItem, setPlaygroundResponse, selectPlaygroundResponse } from '../../../../../store/slices/playground'; import { getItemName, isUnsupportedRequest } from '../../../../../utils/schemaHelpers'; +import { getInheritedAuthSummary } from '../../../../../utils/request'; import UnsupportedRequest from '../../../../UnsupportedRequest/UnsupportedRequest'; import { FileNotFoundIcon } from '../../../../../assets/icons'; import { useSplitPane } from '../../../../../hooks/useSplitPane'; @@ -39,6 +40,10 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec () => (collection && itemUuid ? getAncestorsByUuid(collection, itemUuid) : []), [collection, itemUuid] ); + const inheritedAuth = useMemo( + () => getInheritedAuthSummary(collection, ancestry, editableItem), + [collection, ancestry, editableItem] + ); const saveTimeoutRef = useRef(null); const pendingSaveRef = useRef<{ uuid: string; item: HttpRequest } | null>(null); @@ -127,7 +132,7 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec : { width: `${paneSize}%`, borderColor: 'var(--border-color)' } } > - + diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx index 9cec8209..da7c3384 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx @@ -29,14 +29,15 @@ import { getItemDocs } from '../../../../../../utils/schemaHelpers'; import { setUrlQueryParams } from '../../../../../../utils/pathParams'; -import { actionsToPostResponseVars, postResponseVarsToActions } from '../../../../../../utils/request'; +import { actionsToPostResponseVars, postResponseVarsToActions, type InheritedAuthSummary } from '../../../../../../utils/request'; interface RequestPaneProps { item: HttpRequest; onItemChange: (item: HttpRequest) => void; + inheritedAuth?: InheritedAuthSummary; } -const RequestPane: React.FC = ({ item, onItemChange }) => { +const RequestPane: React.FC = ({ item, onItemChange, inheritedAuth }) => { const [activeTab, setActiveTab] = useState('overview'); const handleParamsChange = (params: KeyValueRow[]) => { @@ -179,6 +180,7 @@ const RequestPane: React.FC = ({ item, onItemChange }) => { description="Configures authentication for this request." showInherit={true} showFullAuth={true} + inheritedAuth={inheritedAuth} /> ); diff --git a/packages/oc-docs/src/runner/index.spec.ts b/packages/oc-docs/src/runner/index.spec.ts index a4fd34fd..a88df094 100644 --- a/packages/oc-docs/src/runner/index.spec.ts +++ b/packages/oc-docs/src/runner/index.spec.ts @@ -316,5 +316,148 @@ describe('RequestRunner', () => { global.fetch = originalFetch; }); + describe("inherited auth reaches the wire", () => { + const run = async (yaml: string, path: number[]) => { + let captured: { url?: string; headers?: Record } = {}; + global.fetch = vi + .fn() + .mockImplementation((url: string, init: RequestInit) => { + captured = { url, headers: init.headers as Record }; + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + url, + headers: new Headers({ "content-type": "application/json" }), + text: async () => JSON.stringify({ ok: true }), + }); + }); + const collection = parseYaml(yaml); + let item = collection; + for (const idx of path) item = (item.items ?? [])[idx]; + await new RequestRunner().runRequest({ + item, + collection, + runtimeVariables: {}, + timeout: 5000, + }); + global.fetch = originalFetch; + return captured; + }; + + it("applies a collection-level bearer to an inheriting request, interpolating the token var", async () => { + const captured = await run( + ` +opencollection: "1.0.0" +info: + name: "Auth Collection" +request: + variables: + - name: "apiToken" + value: "secret-abc" + auth: + type: "bearer" + token: "{{apiToken}}" +items: + - name: "Inherited Bearer" + type: "http" + method: "GET" + url: "https://api.example.com/me" + auth: inherit +`, + [0], + ); + expect(captured.headers?.["Authorization"]).toBe("Bearer secret-abc"); + }); + + it("applies a folder-level api key (query placement) to an inheriting request", async () => { + const captured = await run( + ` +opencollection: "1.0.0" +info: + name: "Auth Collection" +items: + - name: "Folder A" + type: "folder" + request: + auth: + type: "apikey" + key: "api_key" + value: "k-123" + placement: "query" + items: + - name: "Inherited ApiKey" + type: "http" + method: "GET" + url: "https://api.example.com/data" + auth: inherit +`, + [0, 0], + ); + expect(captured.url).toContain("api_key=k-123"); + }); + + it("prefers the closest folder auth over the collection for an inheriting request", async () => { + const captured = await run( + ` +opencollection: "1.0.0" +info: + name: "Auth Collection" +request: + auth: + type: "bearer" + token: "collection-token" +items: + - name: "Folder A" + type: "folder" + request: + auth: + type: "bearer" + token: "folder-token" + items: + - name: "Inherited Bearer" + type: "http" + method: "GET" + url: "https://api.example.com/me" + auth: inherit +`, + [0, 0], + ); + expect(captured.headers?.["Authorization"]).toBe("Bearer folder-token"); + }); + + it("sends No Auth when a No-Auth folder blocks the collection auth", async () => { + // A folder that configured No Auth is represented by an absent `auth` field (canonical + // OpenCollection has no 'none' string); it blocks the parent's auth for an inheriting child. + const captured = await run( + ` +opencollection: "1.0.0" +info: + name: "Auth Collection" +request: + auth: + type: "bearer" + token: "collection-token" +items: + - name: "Folder A" + type: "folder" + request: + headers: + - name: "X-From-Folder" + value: "1" + items: + - name: "Inheriting Request" + type: "http" + method: "GET" + url: "https://api.example.com/me" + auth: inherit +`, + [0, 0], + ); + expect(captured.headers?.["Authorization"]).toBeUndefined(); + expect(captured.headers?.["X-From-Folder"]).toBe("1"); // folder headers still merge + }); + }); + }); }); diff --git a/packages/oc-docs/src/runner/utils/request-merger.spec.ts b/packages/oc-docs/src/runner/utils/request-merger.spec.ts index e9601287..dfd0990f 100644 --- a/packages/oc-docs/src/runner/utils/request-merger.spec.ts +++ b/packages/oc-docs/src/runner/utils/request-merger.spec.ts @@ -86,4 +86,36 @@ describe('mergeAuth (inherited-auth resolution for the playground send path)', ( expect(coll.request.auth.token).toBe('t'); // mutation does not leak back to the collection }); }); + + // The resolver is mode-agnostic: it copies the whole auth object through by reference to its + // shape, never switching on `auth.type`. These lock that in so a future auth type inherits with + // no change here, and so nobody re-introduces a per-type branch that would break new types. + describe('scalability — any auth type inherits without changes to the resolver', () => { + it('resolves an unknown/future auth type through the chain, untouched', () => { + const future: any = { type: 'future-scheme-v9', apiToken: 't', nested: { region: 'us' } }; + expect(resolve(httpRequest('inherit'), collection(future))).toMatchObject(future); + }); + + it('applies closest-folder-wins for an unknown type just like a known one', () => { + const outer: any = { type: 'scheme-a', k: '1' }; + const inner: any = { type: 'scheme-b', k: '2' }; + const path = [folder('outer', outer), folder('inner', inner)]; + expect(resolve(httpRequest('inherit'), collection(undefined), path)).toMatchObject(inner); + }); + + it('deep-clones a nested auth shape so nothing aliases the shared source', () => { + const coll = collection({ + type: 'oauth2', + grantType: 'client_credentials', + additionalParameters: [{ name: 'scope', value: 'read' }] + }); + const request = httpRequest('inherit'); + mergeAuth(coll, request, []); + const applied = getRequestAuth(request) as any; + expect(applied).toMatchObject({ type: 'oauth2', grantType: 'client_credentials' }); + expect(applied.additionalParameters).not.toBe(coll.request.auth.additionalParameters); // nested array cloned + applied.additionalParameters[0].value = 'mutated'; + expect(coll.request.auth.additionalParameters[0].value).toBe('read'); // no leak into the collection + }); + }); }); diff --git a/packages/oc-docs/src/runner/utils/request-merger.ts b/packages/oc-docs/src/runner/utils/request-merger.ts index a130b62b..0ac14101 100644 --- a/packages/oc-docs/src/runner/utils/request-merger.ts +++ b/packages/oc-docs/src/runner/utils/request-merger.ts @@ -52,7 +52,7 @@ export const mergeHeaders = (collection: OpenCollection, request: HttpRequest, r if (!request.http.headers) { request.http.headers = []; } - + // Merge with existing request headers (request headers take precedence) const requestHeaderMap = new Map(); currentHeaders.forEach((header) => { @@ -79,9 +79,9 @@ const isConcreteAuth = (auth: Auth | undefined): auth is Exclude { if (getRequestAuth(request) !== 'inherit') { @@ -103,8 +103,11 @@ export const mergeAuth = (collection: OpenCollection, request: HttpRequest, requ if (!request.http) { request.http = {}; } - // Clone so the per-run request never aliases the shared collection/folder auth object. - request.http.auth = effective ? { ...effective } : undefined; + // The whole resolved auth object is copied through untouched, whatever its `type` — this resolver + // is deliberately mode-agnostic, so a new auth type inherits with no change here. Deep-clone so + // the per-run request never aliases the shared collection/folder auth, including any nested + // structure a richer auth type carries (e.g. oauth2 additional-parameter arrays). + request.http.auth = effective ? JSON.parse(JSON.stringify(effective)) : undefined; }; /** @@ -132,7 +135,7 @@ export const mergeScripts = ( // Initialize merged scripts as object const mergedScriptsObj: Record = {}; - + if (flow === 'sandwich') { // Sandwich flow: collection -> folders -> request -> folders (reverse) -> collection const preRequestParts: string[] = []; @@ -185,7 +188,7 @@ export const mergeScripts = ( if (requestScriptsObj.tests) { testsParts.push(requestScriptsObj.tests); } - + mergedScriptsObj.preRequest = preRequestParts.length > 0 ? preRequestParts.join('\n\n') : undefined; mergedScriptsObj.postResponse = postResponseParts.length > 0 ? postResponseParts.join('\n\n') : undefined; mergedScriptsObj.tests = testsParts.length > 0 ? testsParts.join('\n\n') : undefined; diff --git a/packages/oc-docs/src/runner/utils/tree-utils.spec.ts b/packages/oc-docs/src/runner/utils/tree-utils.spec.ts new file mode 100644 index 00000000..13675dd7 --- /dev/null +++ b/packages/oc-docs/src/runner/utils/tree-utils.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { getTreePathFromCollectionToItem } from './tree-utils'; +import { getItemName } from '../../utils/schemaHelpers'; + +// Two requests that are byte-identical in name+method+url but live in different folders. Only the +// hydrated uuid tells them apart, so the ancestor walk must key on uuid — content matching would +// resolve whichever copy appears first in tree order and inherit the wrong folder's auth. +const request = (uuid: string): any => ({ + uuid, + info: { type: 'http', name: 'Get User' }, + http: { method: 'GET', url: 'https://api.example.com/users' } +}); +const folder = (uuid: string, name: string, child: any): any => ({ + uuid, + info: { type: 'folder', name }, + items: [child] +}); + +describe('getTreePathFromCollectionToItem', () => { + it('resolves the ancestor chain by uuid so duplicate requests map to their own folder', () => { + const inA = request('req-a'); + const inB = request('req-b'); + const collection: any = { items: [folder('folder-a', 'A', inA), folder('folder-b', 'B', inB)] }; + + // Content matching would return ['A'] for both copies; uuid keeps them distinct. + expect(getTreePathFromCollectionToItem(collection, inA).map(getItemName)).toEqual(['A']); + expect(getTreePathFromCollectionToItem(collection, inB).map(getItemName)).toEqual(['B']); + }); + + it('falls back to content matching when the target carries no uuid (unhydrated input)', () => { + const req = { info: { type: 'http', name: 'Solo' }, http: { method: 'GET', url: 'https://x' } }; + const collection: any = { items: [{ info: { type: 'folder', name: 'F' }, items: [req] }] }; + expect(getTreePathFromCollectionToItem(collection, req as any).map(getItemName)).toEqual(['F']); + }); +}); diff --git a/packages/oc-docs/src/runner/utils/tree-utils.ts b/packages/oc-docs/src/runner/utils/tree-utils.ts index 40558ce3..d3e13fed 100644 --- a/packages/oc-docs/src/runner/utils/tree-utils.ts +++ b/packages/oc-docs/src/runner/utils/tree-utils.ts @@ -2,16 +2,28 @@ import type { OpenCollection } from '@opencollection/types'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { Item, Folder } from '@opencollection/types/collection/item'; import { getItemName, getHttpMethod, getRequestUrl, isFolder, isHttpRequest } from '../../utils/schemaHelpers'; +import { getAncestorsByUuid, findItemByUuid } from '../../utils/fileUtils'; +import { getItemUuid } from '../../utils/itemUtils'; /** - * Find the path from collection root to a specific item + * Find the folder ancestors from the collection root to a specific item (the item excluded). + * + * Resolution prefers the stable hydrated `uuid`: two requests can share the same + * name+method+url (e.g. a copy in another folder), and matching by content would return the + * wrong one's ancestor chain — and therefore inherit auth/headers/variables from the wrong + * parent. Content matching is kept only as a fallback for unhydrated input that carries no uuid. */ export const getTreePathFromCollectionToItem = ( - collection: OpenCollection, + collection: OpenCollection, targetItem: HttpRequest ): Item[] => { + const uuid = getItemUuid(targetItem); + if (uuid && findItemByUuid(collection.items, uuid)) { + return getAncestorsByUuid(collection, uuid); + } + const path: Item[] = []; - + const findItemPath = (items: Item[] | undefined, currentPath: Item[] = []): boolean => { if (!items) return false; diff --git a/packages/oc-docs/src/utils/request.spec.ts b/packages/oc-docs/src/utils/request.spec.ts index 61248329..d89f4972 100644 --- a/packages/oc-docs/src/utils/request.spec.ts +++ b/packages/oc-docs/src/utils/request.spec.ts @@ -43,6 +43,41 @@ describe('requestAuth', () => { expect(resolved.auth).toMatchObject({ type: 'bearer' }); expect(resolved.source).toEqual({ level: 'collection', name: 'C' }); }); + + it('lets an explicit No-Auth folder block a parent folder auth (closest choice wins)', () => { + const item: any = { runtime: { auth: 'inherit' } }; + const outer: any = { info: { type: 'folder', name: 'outer' }, request: { auth: { type: 'bearer', token: 't' } } }; + const inner: any = { info: { type: 'folder', name: 'inner' }, request: { auth: undefined } }; // No Auth + const resolved = resolveInheritedAuth({ info: { name: 'C' } } as any, [outer, inner], item); + expect(resolved.auth).toBeUndefined(); + expect(resolved.source).toEqual({ level: 'folder', name: 'inner' }); + }); + + it('is transparent through an inherit folder to a shallower concrete folder', () => { + const item: any = { runtime: { auth: 'inherit' } }; + const outer: any = { info: { type: 'folder', name: 'outer' }, request: { auth: { type: 'bearer', token: 't' } } }; + const inner: any = { info: { type: 'folder', name: 'inner' }, request: { auth: 'inherit' } }; + const resolved = resolveInheritedAuth({ info: { name: 'C' } } as any, [outer, inner], item); + expect(resolved.auth).toMatchObject({ type: 'bearer' }); + expect(resolved.source).toEqual({ level: 'folder', name: 'outer' }); + }); + + it('resolves to No Auth (never the literal "inherit") when nothing up the chain configures auth', () => { + const item: any = { runtime: { auth: 'inherit' } }; + const folder: any = { info: { type: 'folder', name: 'F' }, request: { auth: 'inherit' } }; + const resolved = resolveInheritedAuth({ info: { name: 'C' }, request: { auth: 'inherit' } } as any, [folder], item); + expect(resolved.auth).toBeUndefined(); + }); + + it('resolves and labels an unknown/future auth type without a per-type change (scalable)', () => { + const future: any = { type: 'future-scheme-v9', token: 't' }; + const item: any = { runtime: { auth: 'inherit' } }; + const folder: any = { info: { type: 'folder', name: 'F' }, request: { auth: future } }; + const resolved = resolveInheritedAuth({ info: { name: 'C' } } as any, [folder], item); + expect(resolved.auth).toMatchObject(future); // resolved through untouched, whatever the type + // The label falls back to the raw type when no friendly label exists yet, so it never breaks. + expect(humanizeAuthMode(future, AUTH_MODE_LABELS)).toBe('future-scheme-v9'); + }); }); describe('requestBody', () => { diff --git a/packages/oc-docs/src/utils/request.ts b/packages/oc-docs/src/utils/request.ts index 9887a1d5..c8c33cfc 100644 --- a/packages/oc-docs/src/utils/request.ts +++ b/packages/oc-docs/src/utils/request.ts @@ -24,6 +24,7 @@ import { import { getItemUuid } from './itemUtils'; import { isSecretVariable, unwrapVariableValue } from './variableResolution'; import { COLLECTION_ROOT_CRUMB } from './common'; +import { AUTH_MODE_LABELS } from '../constants'; export const humanizeAuthMode = (auth: Auth | undefined, labels: Record): string => { if (!auth) return 'No Auth'; @@ -49,11 +50,18 @@ export const resolveInheritedAuth = ( const own = getRequestAuth(item) as Auth | undefined; if (own !== 'inherit') return { auth: own }; + // Walk ancestors leaf->root. Only an `inherit` folder is transparent; the first folder that + // made an auth choice of its own — concrete OR an explicit No Auth (undefined) — is terminal + // and wins. A No-Auth folder therefore blocks a parent's auth rather than being skipped past. + // This mirrors the send path (`runner/utils/request-merger.ts` mergeAuth) and the app's + // inherited-auth resolver, so the docs show exactly what the playground puts on the wire. for (let i = ancestors.length - 1; i >= 0; i -= 1) { const auth = folderAuth(ancestors[i]); - if (isConcrete(auth)) { - return { auth, source: { level: 'folder', name: getItemName(ancestors[i]) || 'Folder' } }; - } + if (auth === 'inherit') continue; + return { + auth: isConcrete(auth) ? auth : undefined, + source: { level: 'folder', name: getItemName(ancestors[i]) || 'Folder' } + }; } const collectionAuth = collection?.request?.auth as Auth | undefined; @@ -61,7 +69,32 @@ export const resolveInheritedAuth = ( return { auth: collectionAuth, source: { level: 'collection', name: collection?.info?.name || 'Collection' } }; } - return { auth: 'inherit' }; + // Nothing concrete anywhere up the chain — the request effectively sends No Auth, not "Inherit". + return { auth: undefined }; +}; + +/** Display summary for an inheriting item: the parent it resolves from and that parent's mode. */ +export interface InheritedAuthSummary { + sourceName: string; + modeLabel: string; +} + +/** + * Resolve the inherited-auth summary an Auth tab shows ("Auth inherited from {name}: {mode}"), + * or undefined when the item doesn't inherit. Names the nearest configured parent (falling back + * to the collection) and its effective mode, matching the desktop app. + */ +export const getInheritedAuthSummary = ( + collection: OpenCollection | null | undefined, + ancestors: Item[], + item: HttpRequest +): InheritedAuthSummary | undefined => { + if (getRequestAuth(item) !== 'inherit') return undefined; + const resolved = resolveInheritedAuth(collection, ancestors, item); + return { + sourceName: resolved.source?.name ?? collection?.info?.name ?? 'Collection', + modeLabel: humanizeAuthMode(resolved.auth, AUTH_MODE_LABELS) + }; }; export const getDescription = (item: unknown): string | undefined => { From efcee9bf285c0fdff25cdec62149bd10bb5a15ab Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 24 Jul 2026 17:43:09 +0530 Subject: [PATCH 3/8] E2E changes and fixes --- .../e2e/components/playground.component.ts | 3 ++ .../oc-docs/e2e/playwright/pages.fixture.ts | 5 --- .../tests/playground/playground-auth.spec.ts | 32 +++++++++---------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/oc-docs/e2e/components/playground.component.ts b/packages/oc-docs/e2e/components/playground.component.ts index ea549374..f58f73d1 100644 --- a/packages/oc-docs/e2e/components/playground.component.ts +++ b/packages/oc-docs/e2e/components/playground.component.ts @@ -2,10 +2,13 @@ import type { Locator } from '@playwright/test'; import { BaseComponent } from './base.component'; import { KeyValueTableComponent } from './key-value-table/key-value-table.component'; import { CodeEditorComponent } from './code-editor/code-editor.component'; +import { RequestAuthComponent } from './playground/auth.component'; import type { DockMode } from '../../src/utils/playgroundDock'; export class PlaygroundComponent extends BaseComponent { readonly keyValueTable = new KeyValueTableComponent(this.page); + // The Auth tab lives inside the playground request pane; open it with selectTab('auth'). + readonly auth = new RequestAuthComponent(this.page); readonly preRequestScriptEditor = new CodeEditorComponent(this.page, 'scripts-editor-pre-request'); readonly postResponseScriptEditor = new CodeEditorComponent(this.page, 'scripts-editor-post-response'); diff --git a/packages/oc-docs/e2e/playwright/pages.fixture.ts b/packages/oc-docs/e2e/playwright/pages.fixture.ts index 3329fec8..c831dda1 100644 --- a/packages/oc-docs/e2e/playwright/pages.fixture.ts +++ b/packages/oc-docs/e2e/playwright/pages.fixture.ts @@ -9,7 +9,6 @@ import { SidebarComponent } from '../components/sidebar.component'; import { TooltipComponent } from '../components/tooltip.component'; import { PlaygroundComponent } from '../components/playground.component'; import { RequestBodyComponent } from '../components/playground/request-body.component'; -import { RequestAuthComponent } from '../components/playground/auth.component'; import { EnvEditorComponent } from '../components/environments/env-editor.component'; import { CollectionSettingsComponent } from '../components/collection-settings/collection-settings.component'; import { ThemeToggleComponent } from '../components/layout/theme-toggle.component'; @@ -28,7 +27,6 @@ type Fixtures = { tooltip: TooltipComponent; playground: PlaygroundComponent; requestBody: RequestBodyComponent; - requestAuth: RequestAuthComponent; envEditor: EnvEditorComponent; collectionSettings: CollectionSettingsComponent; pageHeader: PageHeaderComponent; @@ -74,9 +72,6 @@ export const test = base.extend({ requestBody: async ({ page }, use) => { await use(new RequestBodyComponent(page)); }, - requestAuth: async ({ page }, use) => { - await use(new RequestAuthComponent(page)); - }, envEditor: async ({ page }, use) => { await use(new EnvEditorComponent(page)); }, diff --git a/packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts b/packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts index 134d4e43..7796524a 100644 --- a/packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts +++ b/packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts @@ -6,43 +6,41 @@ test.describe('Playground request — inherited auth', () => { test.use({ viewport: DESKTOP }); test('the auth dropdown offers Inherit alongside the four supported types, and nothing more', async ({ - playground, - requestAuth + playground }) => { await playground.open('bottom'); await playground.openRequest('get users'); await playground.selectTab('auth'); - await requestAuth.open(); - await expect(requestAuth.option('none')).toBeVisible(); - await expect(requestAuth.option('inherit')).toBeVisible(); - await expect(requestAuth.option('basic')).toBeVisible(); - await expect(requestAuth.option('bearer')).toBeVisible(); - await expect(requestAuth.option('apikey')).toBeVisible(); + await playground.auth.open(); + await expect(playground.auth.option('none')).toBeVisible(); + await expect(playground.auth.option('inherit')).toBeVisible(); + await expect(playground.auth.option('basic')).toBeVisible(); + await expect(playground.auth.option('bearer')).toBeVisible(); + await expect(playground.auth.option('apikey')).toBeVisible(); // No Auth + Inherit + Basic + Bearer + API Key — no other auth types are offered. - await expect(requestAuth.options).toHaveCount(5); + await expect(playground.auth.options).toHaveCount(5); }); test('choosing Inherit shows the inherited-auth notice and marks the mode selected', async ({ - playground, - requestAuth + playground }) => { await playground.open('bottom'); await playground.openRequest('get users'); await playground.selectTab('auth'); - await requestAuth.selectMode('inherit'); - await expect(requestAuth.modeSelect).toContainText('Inherit'); - await expect(requestAuth.inheritNotice).toBeVisible(); + await playground.auth.selectMode('inherit'); + await expect(playground.auth.modeSelect).toContainText('Inherit'); + await expect(playground.auth.inheritNotice).toBeVisible(); }); - test('a request already set to inherit opens with Inherit preselected', async ({ playground, requestAuth }) => { + test('a request already set to inherit opens with Inherit preselected', async ({ playground }) => { await playground.open('bottom'); // Get All Customers inherits its auth from the Bearer-authed collection. await playground.openTreeItem(['billing', 'customers', 'Get All Customers']); await playground.selectTab('auth'); - await expect(requestAuth.modeSelect).toContainText('Inherit'); - await expect(requestAuth.inheritNotice).toBeVisible(); + await expect(playground.auth.modeSelect).toContainText('Inherit'); + await expect(playground.auth.inheritNotice).toBeVisible(); }); }); From a02a2c6f4883e9d8e8d6b86c8aeebeeb6e063a04 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 24 Jul 2026 17:54:29 +0530 Subject: [PATCH 4/8] E2E changes and fixes --- packages/oc-docs/e2e/components/playground/auth.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/oc-docs/e2e/components/playground/auth.component.ts b/packages/oc-docs/e2e/components/playground/auth.component.ts index 147a3819..dde5129d 100644 --- a/packages/oc-docs/e2e/components/playground/auth.component.ts +++ b/packages/oc-docs/e2e/components/playground/auth.component.ts @@ -7,8 +7,7 @@ import { BaseComponent } from '../base.component'; export class RequestAuthComponent extends BaseComponent { readonly modeSelect = this.page.getByTestId('auth-mode-select'); readonly inheritNotice = this.page.getByTestId('auth-inherit-notice'); - // Options render as role="option" inside the (listbox) dropdown once it is open. - readonly options = this.page.getByRole('option'); + readonly options = this.page.getByTestId(/^auth-mode-select-(?!dropdown$)/); option(value: string): Locator { return this.page.getByTestId(`auth-mode-select-${value}`); From b3e005507d0dbef6b3f36462f2216445fecdaad0 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 24 Jul 2026 18:16:27 +0530 Subject: [PATCH 5/8] Conflicts resolved --- packages/oc-docs/e2e/components/playground.component.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/oc-docs/e2e/components/playground.component.ts b/packages/oc-docs/e2e/components/playground.component.ts index 6f2fbadc..2769b158 100644 --- a/packages/oc-docs/e2e/components/playground.component.ts +++ b/packages/oc-docs/e2e/components/playground.component.ts @@ -58,10 +58,6 @@ export class PlaygroundComponent extends BaseComponent { return this.sidebarPanel.getByTestId('sidebar-example').filter({ hasText: exampleName }); } - async open(dock: DockMode = 'bottom'): Promise { - await this.page.goto(`/#/?pg=1&dock=${dock}`); - } - sidebarItem(name: string): Locator { return this.treeItems.filter({ hasText: name }).first(); } @@ -92,8 +88,8 @@ export class PlaygroundComponent extends BaseComponent { return this.page.getByTestId(`playground-dock-${mode}-panel`); } - async open(mode: DockMode): Promise { - await this.page.goto(`/#/?pg=1&dock=${mode}`); + async open(dock: DockMode = 'bottom'): Promise { + await this.page.goto(`/#/?pg=1&dock=${dock}`); await this.runner.waitFor({ state: 'visible' }); } From 6e037b22555d6472f04a866ada98d5f358e1244c Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Sat, 25 Jul 2026 23:24:59 +0530 Subject: [PATCH 6/8] Claude comments addressed --- .../Content/Views/Common/AuthTab/AuthTab.tsx | 6 +-- .../FolderSettingsView/FolderSettingsView.tsx | 4 +- .../RequestPane/RequestPane.tsx | 1 - .../src/runner/utils/request-merger.ts | 41 +++++-------------- .../oc-docs/src/runner/utils/tree-utils.ts | 8 ++-- packages/oc-docs/src/utils/request.ts | 10 ++--- 6 files changed, 26 insertions(+), 44 deletions(-) diff --git a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx index a2290059..a9fcb8b2 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/Common/AuthTab/AuthTab.tsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import { Field, type SelectOption } from '../../../../../../ui/Field'; import { AUTH_DEFAULTS, AUTH_MODE_LABELS, PLACEMENT_OPTIONS } from '../../../../../../constants'; import { REQUEST_PROTOCOL_KEYS } from '../../../../../../utils/schemaHelpers'; @@ -145,12 +145,12 @@ export const AuthTab: React.FC = ({ return (

{inheritedAuth ? ( - + <> Auth inherited from {inheritedAuth.sourceName}:{' '} {inheritedAuth.modeLabel} - + ) : ( 'Auth inherited from the parent folder or collection.' )} diff --git a/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx b/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx index 18f4b072..d4af3854 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx @@ -1,7 +1,6 @@ import React, { useMemo, useState } from 'react'; import type { Folder } from '@opencollection/types/collection/item'; import type { OpenCollection } from '@opencollection/types'; -import type { HttpRequest } from '@opencollection/types/requests/http'; import Tabs from '../../../../../ui/Tabs/Tabs'; import TitleLabel from '../../../../TitleLabel/TitleLabel'; import { type KeyValueRow } from '../../../../../components/KeyValueTable/KeyValueTable'; @@ -36,9 +35,10 @@ const FolderSettings: React.FC = ({ folder, collection, onF const [activeTab, setActiveTab] = useState('overview'); const inheritedAuth = useMemo(() => { + if (folder.request?.auth !== 'inherit') return undefined; const uuid = getItemUuid(folder); const ancestry = uuid ? getAncestorsByUuid(collection, uuid) : []; - return getInheritedAuthSummary(collection, ancestry, folder as unknown as HttpRequest); + return getInheritedAuthSummary(collection, ancestry, folder); }, [folder, collection]); const handleHeadersChange = (headers: KeyValueRow[]) => { diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx index 6fbabe91..9e6c5f29 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx @@ -175,7 +175,6 @@ const RequestPane: React.FC = ({ item, onItemChange, inherited onAuthChange={() => {}} // Not used for full auth onItemChange={onItemChange} item={item} - title="" description="Configures authentication for this request." showInherit={true} showFullAuth={true} diff --git a/packages/oc-docs/src/runner/utils/request-merger.ts b/packages/oc-docs/src/runner/utils/request-merger.ts index 0ac14101..970d220f 100644 --- a/packages/oc-docs/src/runner/utils/request-merger.ts +++ b/packages/oc-docs/src/runner/utils/request-merger.ts @@ -13,7 +13,7 @@ import { scriptsObjectToArray, type ScriptsObject } from '../../utils/schemaHelpers'; -import type { Auth } from '@opencollection/types/common/auth'; +import { resolveInheritedAuth } from '../../utils/request'; /** * Merge headers from collection and folder hierarchy into the request @@ -67,47 +67,28 @@ export const mergeHeaders = (collection: OpenCollection, request: HttpRequest, r }); }; -// A concrete auth is a configured mode (basic/bearer/apikey/…), as opposed to the -// `inherit` sentinel or an absent value (No Auth). -const isConcreteAuth = (auth: Auth | undefined): auth is Exclude => !!auth && auth !== 'inherit'; - /** - * Resolve inherited authentication into the request, matching the desktop app's send path - * (bruno-electron `mergeAuth`). Only an explicit `inherit` resolves; a request with its own - * concrete auth — or an explicit No Auth — is left untouched, so a parent never overrides an - * explicit choice (acceptance: an explicit No Auth is respected). - * - * Walking the chain outward-in, the closest level that made an explicit auth choice wins: a - * folder set to a concrete mode supplies it, a folder set to No Auth blocks a parent's auth, - * and a folder left on `inherit` is transparent. The display resolver (`utils/request.ts` - * resolveInheritedAuth) applies the identical rule, so the docs show exactly what the - * playground puts on the wire. + * Resolve inherited authentication into the request for the send path. Delegates to the shared + * resolver (`utils/request.ts` resolveInheritedAuth) so the wire and the docs display can never + * drift: only an explicit `inherit` resolves; the closest level that made an auth choice wins (a + * concrete mode applies, an explicit No Auth blocks a parent, an `inherit` folder is transparent) — + * matching the desktop app (bruno-electron `mergeAuth`). */ export const mergeAuth = (collection: OpenCollection, request: HttpRequest, requestTreePath: Item[] = []): void => { if (getRequestAuth(request) !== 'inherit') { return; } - // The collection is the base; folders nearer the request override it. - const collectionAuth = collection.request?.auth as Auth | undefined; - let effective: Exclude | undefined = isConcreteAuth(collectionAuth) ? collectionAuth : undefined; - - for (const item of requestTreePath) { - if (!isFolder(item)) continue; - const folderAuth = (item as { request?: { auth?: Auth } }).request?.auth; - if (folderAuth === 'inherit') continue; // transparent — keep looking outward-in - effective = isConcreteAuth(folderAuth) ? folderAuth : undefined; // concrete applies; No Auth blocks - } + const { auth } = resolveInheritedAuth(collection, requestTreePath, request); // Auth lives on the protocol-detail block (request.http.auth); ensure it exists. if (!request.http) { request.http = {}; } - // The whole resolved auth object is copied through untouched, whatever its `type` — this resolver - // is deliberately mode-agnostic, so a new auth type inherits with no change here. Deep-clone so - // the per-run request never aliases the shared collection/folder auth, including any nested - // structure a richer auth type carries (e.g. oauth2 additional-parameter arrays). - request.http.auth = effective ? JSON.parse(JSON.stringify(effective)) : undefined; + // The resolved auth is copied through untouched whatever its `type` (mode-agnostic). Deep-clone so + // the per-run request never aliases the shared collection/folder auth, including nested structure + // a richer type carries (e.g. oauth2 additional-parameter arrays). + request.http.auth = auth ? JSON.parse(JSON.stringify(auth)) : undefined; }; /** diff --git a/packages/oc-docs/src/runner/utils/tree-utils.ts b/packages/oc-docs/src/runner/utils/tree-utils.ts index d3e13fed..022ac9d0 100644 --- a/packages/oc-docs/src/runner/utils/tree-utils.ts +++ b/packages/oc-docs/src/runner/utils/tree-utils.ts @@ -2,7 +2,7 @@ import type { OpenCollection } from '@opencollection/types'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { Item, Folder } from '@opencollection/types/collection/item'; import { getItemName, getHttpMethod, getRequestUrl, isFolder, isHttpRequest } from '../../utils/schemaHelpers'; -import { getAncestorsByUuid, findItemByUuid } from '../../utils/fileUtils'; +import { getAncestorsByUuid } from '../../utils/fileUtils'; import { getItemUuid } from '../../utils/itemUtils'; /** @@ -11,14 +11,16 @@ import { getItemUuid } from '../../utils/itemUtils'; * Resolution prefers the stable hydrated `uuid`: two requests can share the same * name+method+url (e.g. a copy in another folder), and matching by content would return the * wrong one's ancestor chain — and therefore inherit auth/headers/variables from the wrong - * parent. Content matching is kept only as a fallback for unhydrated input that carries no uuid. + * parent. `getAncestorsByUuid` returns [] when the uuid isn't in the collection (an unhydrated or + * stale item), which resolves from the collection only — safer than content-matching to a possibly + * wrong duplicate. Content matching is kept only as a fallback for input that carries no uuid. */ export const getTreePathFromCollectionToItem = ( collection: OpenCollection, targetItem: HttpRequest ): Item[] => { const uuid = getItemUuid(targetItem); - if (uuid && findItemByUuid(collection.items, uuid)) { + if (uuid) { return getAncestorsByUuid(collection, uuid); } diff --git a/packages/oc-docs/src/utils/request.ts b/packages/oc-docs/src/utils/request.ts index c8c33cfc..030dcf9b 100644 --- a/packages/oc-docs/src/utils/request.ts +++ b/packages/oc-docs/src/utils/request.ts @@ -45,9 +45,9 @@ const isConcrete = (auth: Auth | undefined): boolean => !!auth && auth !== 'inhe export const resolveInheritedAuth = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest + item: Item ): ResolvedAuth => { - const own = getRequestAuth(item) as Auth | undefined; + const own = getRequestAuth(item as HttpRequest) as Auth | undefined; if (own !== 'inherit') return { auth: own }; // Walk ancestors leaf->root. Only an `inherit` folder is transparent; the first folder that @@ -87,12 +87,12 @@ export interface InheritedAuthSummary { export const getInheritedAuthSummary = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest + item: Item ): InheritedAuthSummary | undefined => { - if (getRequestAuth(item) !== 'inherit') return undefined; + if (getRequestAuth(item as HttpRequest) !== 'inherit') return undefined; const resolved = resolveInheritedAuth(collection, ancestors, item); return { - sourceName: resolved.source?.name ?? collection?.info?.name ?? 'Collection', + sourceName: resolved.source?.name || collection?.info?.name || 'Collection', modeLabel: humanizeAuthMode(resolved.auth, AUTH_MODE_LABELS) }; }; From 28e879b4e5941f5c577ab304956170dcc193b172 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Sat, 25 Jul 2026 23:51:58 +0530 Subject: [PATCH 7/8] Fixed E2E test case failure issue --- .../src/ui/CodeEditor/scriptApiCompletions.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/oc-docs/src/ui/CodeEditor/scriptApiCompletions.ts b/packages/oc-docs/src/ui/CodeEditor/scriptApiCompletions.ts index 327f5f84..301d967e 100644 --- a/packages/oc-docs/src/ui/CodeEditor/scriptApiCompletions.ts +++ b/packages/oc-docs/src/ui/CodeEditor/scriptApiCompletions.ts @@ -19,6 +19,21 @@ export const setModelHints = (model: editor.ITextModel, roots: ScriptApiRoot[]): /** Register the shared Bruno API completion provider for JavaScript, once per Monaco instance. */ export const ensureScriptApiCompletions = (monaco: Monaco): void => { if (providerDisposable) return; + + // The script editors use the `javascript` language, so Monaco's built-in TS/JS language service + // also contributes completions (DOM globals like `ReportBody`/`ReadableStream`). Those are noise + // in a Bruno script and, because the TS worker answers asynchronously, they race the Bruno + // provider — which shows up as flaky autocomplete on slower CI. Turn the built-in completion items + // off so the Bruno provider is the single, synchronous source, matching this module's design + // ("models without an entry get no suggestions"). + const jsDefaults = monaco.languages.typescript?.javascriptDefaults; + if (jsDefaults) { + // getModeConfiguration exists at runtime but is missing from this monaco version's types. + const current = (jsDefaults as { getModeConfiguration?(): languages.typescript.ModeConfiguration }) + .getModeConfiguration?.() ?? {}; + jsDefaults.setModeConfiguration({ ...current, completionItems: false }); + } + providerDisposable = monaco.languages.registerCompletionItemProvider('javascript', { triggerCharacters: ['.'], provideCompletionItems(model, position) { From 7024ea35510294591c558f279801a179aea1e392 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Sun, 26 Jul 2026 00:17:55 +0530 Subject: [PATCH 8/8] Fixed E2E test case failure issue --- .../code-editor/code-editor.component.ts | 6 +++++ .../oc-docs/src/ui/CodeEditor/CodeEditor.tsx | 24 +++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/oc-docs/e2e/components/code-editor/code-editor.component.ts b/packages/oc-docs/e2e/components/code-editor/code-editor.component.ts index f2bb3c53..3a2e6b2b 100644 --- a/packages/oc-docs/e2e/components/code-editor/code-editor.component.ts +++ b/packages/oc-docs/e2e/components/code-editor/code-editor.component.ts @@ -11,6 +11,7 @@ export class CodeEditorComponent extends BaseComponent { readonly suggestions: Locator; private readonly surface: Locator; private readonly lines: Locator; + private readonly ready: Locator; constructor(page: Page, testId: string) { super(page, page.getByTestId(testId)); @@ -18,10 +19,15 @@ export class CodeEditorComponent extends BaseComponent { this.surface = this.root.locator('.monaco-editor'); this.lines = this.root.locator('.view-lines'); this.suggestions = page.locator('.suggest-widget.visible'); + this.ready = page.locator(`[data-testid="${testId}"][data-editor-ready="true"]`); } async focus(): Promise { await this.surface.waitFor({ state: 'visible', timeout: 20000 }); + // Autocomplete needs the editor's model tagged with its Bruno API roots. Tagging can lag the + // visible surface when the editor first mounts inside a hidden tab panel, so wait for the + // editor to report ready before typing — otherwise the trigger fires against an untagged model. + await this.ready.waitFor({ state: 'attached', timeout: 20000 }); await this.lines.click(); } } diff --git a/packages/oc-docs/src/ui/CodeEditor/CodeEditor.tsx b/packages/oc-docs/src/ui/CodeEditor/CodeEditor.tsx index da5f8b6e..74f9ca78 100644 --- a/packages/oc-docs/src/ui/CodeEditor/CodeEditor.tsx +++ b/packages/oc-docs/src/ui/CodeEditor/CodeEditor.tsx @@ -55,6 +55,7 @@ const CodeEditor: React.FC = ({ }) => { const mode = useAppSelector((s) => s.theme.mode); const editorRef = useRef(null); + const wrapperRef = useRef(null); useEffect(() => { if (active && editorRef.current) editorRef.current.layout(); @@ -73,14 +74,29 @@ const CodeEditor: React.FC = ({ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyY, () => runAction('editor.foldAll')); editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyJ, () => runAction('editor.action.joinLines')); - if (!hintsFor?.length) return; + const markReady = () => wrapperRef.current?.setAttribute('data-editor-ready', 'true'); + + if (!hintsFor?.length) { + markReady(); + return; + } ensureScriptApiCompletions(monaco); - const model = editor.getModel(); - if (model) setModelHints(model, hintsFor); + // An editor first mounted inside a hidden (display:none) tab panel can have its model attached + // — or swapped for a fresh one — only when it becomes visible, which drops a tag applied at + // mount. Re-tag on every model change so the shared completion provider always resolves this + // editor's roots, and signal readiness once the live model carries the tag. + const tagModel = () => { + const model = editor.getModel(); + if (!model) return; + setModelHints(model, hintsFor); + markReady(); + }; + tagModel(); + editor.onDidChangeModel(tagModel); }; return ( - +