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/e2e/components/playground.component.ts b/packages/oc-docs/e2e/components/playground.component.ts index 75f42bcb..2769b158 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'); readonly bodyEditor = new CodeEditorComponent(this.page, 'body-editor'); @@ -55,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(); } @@ -89,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' }); } 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..dde5129d --- /dev/null +++ b/packages/oc-docs/e2e/components/playground/auth.component.ts @@ -0,0 +1,24 @@ +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'); + readonly options = this.page.getByTestId(/^auth-mode-select-(?!dropdown$)/); + + 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/tests/playground/playground-auth.spec.ts b/packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts new file mode 100644 index 00000000..7796524a --- /dev/null +++ b/packages/oc-docs/e2e/tests/playground/playground-auth.spec.ts @@ -0,0 +1,46 @@ +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 + }) => { + await playground.open('bottom'); + await playground.openRequest('get users'); + await playground.selectTab('auth'); + + 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(playground.auth.options).toHaveCount(5); + }); + + test('choosing Inherit shows the inherited-auth notice and marks the mode selected', async ({ + playground + }) => { + await playground.open('bottom'); + await playground.openRequest('get users'); + await playground.selectTab('auth'); + + 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 }) => { + 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(playground.auth.modeSelect).toContainText('Inherit'); + await expect(playground.auth.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 f878b687..1da4e61f 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(getByTestId(root, 'no-content-text').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(getByTestId(root, 'no-content-text').text.trim()).toBe('Inherits auth from parent 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 d062097f..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 @@ -2,6 +2,7 @@ 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'; +import type { InheritedAuthSummary } from '../../../../../../utils/request'; import { StyledWrapper } from './StyledWrapper'; import NoContentText from '../../../../../../ui/NoContentText/NoContentText'; @@ -14,6 +15,7 @@ interface AuthTabProps { description?: string; showInherit?: boolean; showFullAuth?: boolean; + inheritedAuth?: InheritedAuthSummary; } export const AuthTab: React.FC = ({ @@ -24,7 +26,8 @@ export const AuthTab: React.FC = ({ title, description, showInherit = false, - showFullAuth = false + showFullAuth = false, + inheritedAuth }) => { const authType = typeof auth === 'object' && auth !== null ? auth.type : auth === 'inherit' ? 'inherit' : 'none'; @@ -139,7 +142,20 @@ export const AuthTab: React.FC = ({ return } if (auth === 'inherit') { - return + return ( +

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

+ ); } if (showFullAuth) { return
{renderForm()}
; 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 b7364401..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 @@ -22,6 +22,17 @@ export const StyledWrapper = styled.div` gap: 1rem; } + .auth-empty { + margin: 0; + font-style: italic; + font-size: 0.8125rem; + 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 81c772a6..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,4 +1,4 @@ -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 Tabs from '../../../../../ui/Tabs/Tabs'; @@ -18,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'; @@ -27,10 +30,17 @@ 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(() => { + if (folder.request?.auth !== 'inherit') return undefined; + const uuid = getItemUuid(folder); + const ancestry = uuid ? getAncestorsByUuid(collection, uuid) : []; + return getInheritedAuthSummary(collection, ancestry, folder); + }, [folder, collection]); + const handleHeadersChange = (headers: KeyValueRow[]) => { const originals = folder.request?.headers ?? []; const originalByName = new Map( @@ -160,6 +170,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 2e6c73b0..c0e5fad0 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 f8565023..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 @@ -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[]) => { @@ -174,8 +175,10 @@ const RequestPane: React.FC = ({ item, onItemChange }) => { onAuthChange={() => {}} // Not used for full auth onItemChange={onItemChange} item={item} - showFullAuth={true} 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 new file mode 100644 index 00000000..dfd0990f --- /dev/null +++ b/packages/oc-docs/src/runner/utils/request-merger.spec.ts @@ -0,0 +1,121 @@ +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 + }); + }); + + // 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 1bdf9028..970d220f 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 { resolveInheritedAuth } from '../../utils/request'; /** * Merge headers from collection and folder hierarchy into the request @@ -55,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) => { @@ -71,34 +68,27 @@ export const mergeHeaders = (collection: OpenCollection, request: HttpRequest, r }; /** - * Merge authentication from collection and folder hierarchy into the request + * 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 => { - 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; } - + + const { auth } = resolveInheritedAuth(collection, requestTreePath, request); + // 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; - } - } - - // Finally, check collection-level auth - if (collection.request?.auth && collection.request.auth !== 'inherit') { - request.http.auth = collection.request.auth; - } + // 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; }; /** @@ -126,7 +116,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[] = []; @@ -179,13 +169,13 @@ 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; } 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; 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..022ac9d0 100644 --- a/packages/oc-docs/src/runner/utils/tree-utils.ts +++ b/packages/oc-docs/src/runner/utils/tree-utils.ts @@ -2,16 +2,30 @@ 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 } 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. `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, + collection: OpenCollection, targetItem: HttpRequest ): Item[] => { + const uuid = getItemUuid(targetItem); + if (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/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 ( - + { 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) { 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..030dcf9b 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'; @@ -44,16 +45,23 @@ 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 + // 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: Item +): InheritedAuthSummary | undefined => { + if (getRequestAuth(item as HttpRequest) !== '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 => {