diff --git a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/MicrosoftExcelSharePoint.node.ts b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/MicrosoftExcelSharePoint.node.ts index 97b6e623bc9..decbba2f07c 100644 --- a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/MicrosoftExcelSharePoint.node.ts +++ b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/MicrosoftExcelSharePoint.node.ts @@ -7,6 +7,7 @@ import type { import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import * as readRows from './actions/worksheet/readRows.operation'; +import { listSearch } from './methods'; // Shell for the Excel-on-SharePoint build. Registered but hidden: workflows // using it always work; the launch ticket removes the `hidden` flag. @@ -107,6 +108,10 @@ export class MicrosoftExcelSharePoint implements INodeType { ], }; + methods = { + listSearch, + }; + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const resource = this.getNodeParameter('resource', 0); diff --git a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/actions/worksheet/readRows.operation.ts b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/actions/worksheet/readRows.operation.ts index ae9f517ca10..d7334602987 100644 --- a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/actions/worksheet/readRows.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/actions/worksheet/readRows.operation.ts @@ -184,6 +184,11 @@ export async function execute( // https://learn.microsoft.com/en-us/graph/api/worksheet-range const returnData: INodeExecutionData[] = []; + // Hoisted once for the whole run and passed into resolveWorkbookRoot below, + // so a pasted Workbook/Site address is resolved once, not once per item. + const workbookRootCache = new Map(); + const siteIdCache = new Map(); + for (let i = 0; i < items.length; i++) { try { // Validate everything local before the workbook resolution, which may @@ -195,7 +200,7 @@ export async function execute( qs.$select = settings.fields; } - const workbookRoot = await resolveWorkbookRoot.call(this, i); + const workbookRoot = await resolveWorkbookRoot.call(this, i, workbookRootCache, siteIdCache); const sheetPath = `${workbookRoot}/workbook/worksheets/${encodeURIComponent(settings.worksheetId)}`; // Typed here instead of cast at the call site below. Parens are required: // a generic instantiation can't be followed directly by a property access. diff --git a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/descriptions/common.descriptions.ts b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/descriptions/common.descriptions.ts index 7bbdef738e3..f17215511e8 100644 --- a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/descriptions/common.descriptions.ts +++ b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/descriptions/common.descriptions.ts @@ -38,10 +38,36 @@ export const siteRLC: INodeProperties = { displayName: 'Site', name: 'site', type: 'resourceLocator', - default: { mode: 'id', value: '' }, + default: { mode: 'list', value: '' }, description: 'The SharePoint site the workbook lives in. Only needed when the workbook is chosen by ID.', + // Field-shape-compatible with the site-selection component SharePoint 2.0 + // is building (ENT-182), so the two can converge later. modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchSites', + searchable: true, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'e.g. https://contoso.sharepoint.com/sites/mysite', + validation: [ + { + type: 'regex', + properties: { + regex: 'https://.+', + errorMessage: 'The URL must start with https://', + }, + }, + ], + }, { displayName: 'By ID', name: 'id', @@ -55,10 +81,22 @@ export const libraryRLC: INodeProperties = { displayName: 'Document Library', name: 'library', type: 'resourceLocator', - default: { mode: 'id', value: '' }, + default: { mode: 'list', value: '' }, description: 'The document library the workbook lives in. Only needed when the workbook is chosen by ID.', + typeOptions: { + // So the editor re-fetches the library list whenever the chosen site changes. + loadOptionsDependsOn: ['site.value'], + }, modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLibraries', + }, + }, { displayName: 'By ID', name: 'id', diff --git a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/helpers/utils.ts b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/helpers/utils.ts index b76b246ee11..fa5c4dc1380 100644 --- a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/helpers/utils.ts +++ b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/helpers/utils.ts @@ -1,12 +1,9 @@ import type { IExecuteFunctions, INode, INodeParameterResourceLocator } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import type { AuthContext } from './interfaces'; import { microsoftApiRequest } from '../transport'; -// One lookup per distinct pasted address per execution — without this, a -// multi-item run repeats the same resolution call and risks throttling -const workbookRootCache = new WeakMap>(); - /** * Guards a user-supplied value destined for a URL path segment: rejects empty * values and dots-only values ('..' survives encodeURIComponent and would @@ -31,10 +28,16 @@ export function validatePathSegment(node: INode, label: string, value: string): * Resolves the workbook fields to the Graph root every request hangs off: * `/v1.0/sites/{site}/drives/{drive}/items/{item}`. A pasted address costs one * lookup (Graph's sharing-URL exchange); IDs are used as given. + * + * `workbookRootCache`/`siteIdCache` are the caller's per-execution caches (one + * lookup per distinct pasted address, not one per item) — the caller hoists + * them once above its item loop and passes the same instances every call. */ export async function resolveWorkbookRoot( this: IExecuteFunctions, itemIndex: number, + workbookRootCache: Map = new Map(), + siteIdCache: Map = new Map(), ): Promise { const workbook = this.getNodeParameter('workbook', itemIndex) as INodeParameterResourceLocator; const workbookValue = String(workbook.value ?? '').trim(); @@ -46,12 +49,7 @@ export async function resolveWorkbookRoot( }); } - let cache = workbookRootCache.get(this); - if (!cache) { - cache = new Map(); - workbookRootCache.set(this, cache); - } - const cached = cache.get(workbookValue); + const cached = workbookRootCache.get(workbookValue); if (cached !== undefined) { return cached; } @@ -78,16 +76,12 @@ export async function resolveWorkbookRoot( } const root = `/v1.0/sites/${encodeURIComponent(parent.siteId)}/drives/${encodeURIComponent(parent.driveId)}/items/${encodeURIComponent(itemId)}`; - cache.set(workbookValue, root); + workbookRootCache.set(workbookValue, root); return root; } const node = this.getNode(); - const siteId = validatePathSegment( - node, - 'Site', - String((this.getNodeParameter('site', itemIndex) as INodeParameterResourceLocator).value ?? ''), - ); + const siteId = await resolveSiteId.call(this, itemIndex, siteIdCache); const driveId = validatePathSegment( node, 'Library', @@ -99,3 +93,67 @@ export async function resolveWorkbookRoot( return `/v1.0/sites/${encodeURIComponent(siteId)}/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}`; } + +/** + * Resolves the `site` resource locator to a Graph site ID — the one place + * this happens, reused by workbook-root resolution and the library dropdown. + * IDs (from "By ID" or a "From List" pick) are used as given; a pasted + * address costs one lookup via Graph's `{hostname}:{site-path}` addressing. + * + * `cache` is the caller's per-execution cache; a load-options call (the + * library dropdown) has no loop to amortize, so it just omits one. + */ +export async function resolveSiteId( + this: AuthContext, + itemIndex: number, + cache: Map = new Map(), +): Promise { + const site = this.getNodeParameter('site', itemIndex) as INodeParameterResourceLocator; + const value = String(site.value ?? '').trim(); + + if (site.mode !== 'url') { + return validatePathSegment(this.getNode(), 'Site', value); + } + + if (value === '') { + throw new NodeOperationError(this.getNode(), "The 'Site' parameter is empty", { + description: "Paste the site's address, or switch to choosing it by ID or from the list.", + }); + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new NodeOperationError(this.getNode(), 'The site address is not valid', { + description: 'Paste the full site address, e.g. https://contoso.sharepoint.com/sites/mysite.', + }); + } + const path = parsed.pathname.replace(/\/+$/, ''); + const endpoint = + path === '' ? `/v1.0/sites/${parsed.hostname}` : `/v1.0/sites/${parsed.hostname}:${path}`; + + const cached = cache.get(endpoint); + if (cached !== undefined) { + return cached; + } + + let response; + try { + response = await microsoftApiRequest.call(this, 'GET', endpoint, {}, { $select: 'id' }); + } catch (error) { + // Attribute a failed lookup to the Site field — the transport's generic + // 404 mapping would otherwise blame the operation's resource (the workbook). + if (error instanceof NodeApiError && error.httpCode === '404') { + throw new NodeOperationError(this.getNode(), 'Site not found', { + description: + "Check the value in the 'Site' parameter — the address must point to an existing SharePoint site.", + }); + } + throw error; + } + + const siteId = validatePathSegment(this.getNode(), 'Site', String(response.id ?? '')); + cache.set(endpoint, siteId); + return siteId; +} diff --git a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/methods/index.ts b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/methods/index.ts new file mode 100644 index 00000000000..83691ec395d --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/methods/index.ts @@ -0,0 +1,6 @@ +import { searchLibraries, searchSites } from './listSearch'; + +export const listSearch = { + searchSites, + searchLibraries, +}; diff --git a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/methods/listSearch.ts b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/methods/listSearch.ts new file mode 100644 index 00000000000..b649150826c --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/methods/listSearch.ts @@ -0,0 +1,141 @@ +import type { + IDataObject, + ILoadOptionsFunctions, + INodeListSearchItems, + INodeListSearchResult, +} from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; + +import { SERVICE_PRINCIPAL_AUTH } from '../helpers/constants'; +import { resolveSiteId } from '../helpers/utils'; +import { getExcelSharePointCredentialType, microsoftApiRequest } from '../transport'; + +type GraphCollectionReply = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '@odata.nextLink'?: string; + value?: T[]; +}; + +type Site = { id?: string; displayName?: string; webUrl?: string }; +type Drive = { id?: string; name?: string; webUrl?: string }; + +/** + * Fetches one page of a Graph collection. An explicit `paginationToken` (a + * complete next-page link) is requested exactly as returned, never rebuilt; + * otherwise the initial request is built from `resource`/`qs`. + */ +async function fetchPage( + this: ILoadOptionsFunctions, + resource: string, + qs: IDataObject, + paginationToken?: string, +): Promise> { + return paginationToken + ? await (microsoftApiRequest>).call( + this, + 'GET', + '', + {}, + {}, + paginationToken, + ) + : await (microsoftApiRequest>).call(this, 'GET', resource, {}, qs); +} + +/** + * Maps a page of Graph entries to dropdown items, dropping any without an ID. + * Kept in the API's order: the editor concatenates pages, so a per-page sort + * would reset at every page boundary and read as misordered. + */ +function toListItems( + entries: T[] | undefined, + toItem: (entry: T) => INodeListSearchItems, +): INodeListSearchItems[] { + return (entries ?? []).filter((entry) => entry.id).map(toItem); +} + +function siteToItem(site: Site): INodeListSearchItems { + return { + name: site.displayName ?? String(site.id), + value: String(site.id), + url: site.webUrl, + }; +} + +function driveToItem(drive: Drive): INodeListSearchItems { + return { + name: drive.name ?? String(drive.id), + value: String(drive.id), + url: drive.webUrl, + }; +} + +/** + * An app with only per-site permissions can't list what it can't see — point + * at the "By URL"/"By ID" modes instead of surfacing the raw refusal. + * Delegated refusals keep the transport's message, which already names the + * missing permission, so only the Service Principal case is rewritten. + */ +function toSearchRefusal(this: ILoadOptionsFunctions, error: unknown): Error { + if ( + error instanceof NodeApiError && + error.httpCode === '403' && + getExcelSharePointCredentialType.call(this) === SERVICE_PRINCIPAL_AUTH + ) { + return new NodeOperationError(this.getNode(), 'This app sign-in cannot search sites', { + description: + "An app with only per-site permissions can't list sites it hasn't been granted. Choose the site by pasting its address instead — that still works.", + }); + } + return error as Error; +} + +/** + * Searches sites by name. Graph quirks: the parameter is literally `search` + * (not `$search`), and a site's name lives in `displayName` (no `title`). + */ +export async function searchSites( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + let response: GraphCollectionReply; + try { + response = await (fetchPage).call( + this, + '/v1.0/sites', + { search: filter ?? '*', $select: 'id,displayName,webUrl' }, + paginationToken, + ); + } catch (error) { + throw toSearchRefusal.call(this, error); + } + + return { + results: toListItems(response.value, siteToItem), + paginationToken: response['@odata.nextLink'], + }; +} + +/** Lists the chosen site's document libraries — new ground, no precedent in this node. */ +export async function searchLibraries( + this: ILoadOptionsFunctions, + _filter?: string, + paginationToken?: string, +): Promise { + // Load-options calls have no item index; 0 is a no-op fallback position here + // (mirrors getExcelSharePointCredentialType's use of the same 2-arg form). + const siteId = await resolveSiteId.call(this, 0); + + const response = await (fetchPage).call( + this, + `/v1.0/sites/${encodeURIComponent(siteId)}/drives`, + { $select: 'id,name,webUrl' }, + paginationToken, + ); + + return { + results: toListItems(response.value, driveToItem), + paginationToken: response['@odata.nextLink'], + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/test/helpers/utils.test.ts b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/test/helpers/utils.test.ts index b795d87be96..5859d138faa 100644 --- a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/test/helpers/utils.test.ts +++ b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/test/helpers/utils.test.ts @@ -1,9 +1,10 @@ -import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import type { IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; import type { Mock } from 'vitest'; import type { DeepMockProxy } from 'vitest-mock-extended'; import { mock, mockDeep } from 'vitest-mock-extended'; -import { resolveWorkbookRoot, validatePathSegment } from '../../helpers/utils'; +import { resolveSiteId, resolveWorkbookRoot, validatePathSegment } from '../../helpers/utils'; import * as transport from '../../transport'; import type * as _importType0 from '../../transport'; @@ -118,6 +119,27 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => { "The 'Workbook' value is not valid", ); }); + + it('resolves a pasted Site address before building the root path', async () => { + setParams(ctx, { + workbook: { mode: 'id', value: 'ITEM123' }, + site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' }, + library: { mode: 'id', value: 'b!drive1' }, + }); + apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,g1,g2' }); + + const root = await resolveWorkbookRoot.call(ctx, 0); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/v1.0/sites/contoso.sharepoint.com:/sites/mysite', + {}, + { $select: 'id' }, + ); + expect(root).toBe( + '/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives/b!drive1/items/ITEM123', + ); + }); }); describe('by URL', () => { @@ -152,7 +174,7 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => { ); }); - it('caches the resolution per execution, across items', async () => { + it('caches the resolution per execution, across items, when the caller shares its cache', async () => { setParams(ctx, { workbook: { mode: 'url', value: 'https://contoso.sharepoint.com/book.xlsx' }, }); @@ -161,13 +183,16 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => { parentReference: { siteId: 'contoso.sharepoint.com,g1,g2', driveId: 'b!drive1' }, }); - await resolveWorkbookRoot.call(ctx, 0); - await resolveWorkbookRoot.call(ctx, 1); + // The caller (readRows' execute()) hoists this once per run and passes + // the same instance for every item — that's what makes it a per-run cache. + const workbookRootCache = new Map(); + await resolveWorkbookRoot.call(ctx, 0, workbookRootCache); + await resolveWorkbookRoot.call(ctx, 1, workbookRootCache); expect(apiRequest).toHaveBeenCalledTimes(1); }); - it('does not share the cache across different executions', async () => { + it('resolves again when no cache is passed, or a fresh one is', async () => { const workbook = { mode: 'url', value: 'https://contoso.sharepoint.com/book.xlsx' }; setParams(ctx, { workbook }); apiRequest.mockResolvedValue({ @@ -175,12 +200,8 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => { parentReference: { siteId: 'contoso.sharepoint.com,g1,g2', driveId: 'b!drive1' }, }); - const otherCtx = mockDeep(); - otherCtx.getNode.mockReturnValue(mock()); - setParams(otherCtx, { workbook }); - await resolveWorkbookRoot.call(ctx, 0); - await resolveWorkbookRoot.call(otherCtx, 0); + await resolveWorkbookRoot.call(ctx, 0, new Map()); expect(apiRequest).toHaveBeenCalledTimes(2); }); @@ -211,4 +232,154 @@ describe('Microsoft Excel (SharePoint) — helpers/utils', () => { }); }); }); + + describe('resolveSiteId', () => { + let ctx: DeepMockProxy; + const apiRequest = transport.microsoftApiRequest as Mock; + + const setParams = ( + ctxToSet: DeepMockProxy | DeepMockProxy, + params: Record, + ) => { + ctxToSet.getNodeParameter.mockImplementation( + (name: string, _itemIndex?: number, fallback?: unknown) => + (name in params ? params[name] : fallback) as never, + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + ctx.getNode.mockReturnValue(mock()); + }); + + it('returns an ID as given, from the "By ID" mode', async () => { + setParams(ctx, { site: { mode: 'id', value: 'contoso.sharepoint.com,g1,g2' } }); + + await expect(resolveSiteId.call(ctx, 0)).resolves.toBe('contoso.sharepoint.com,g1,g2'); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('returns an ID as given, from a "From List" pick', async () => { + setParams(ctx, { site: { mode: 'list', value: 'contoso.sharepoint.com,g1,g2' } }); + + await expect(resolveSiteId.call(ctx, 0)).resolves.toBe('contoso.sharepoint.com,g1,g2'); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('rejects an empty Site ID', async () => { + setParams(ctx, { site: { mode: 'id', value: '' } }); + + await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow("The 'Site' parameter is empty"); + }); + + it('rejects a dots-only Site ID', async () => { + setParams(ctx, { site: { mode: 'id', value: '..' } }); + + await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow("The 'Site' value is not valid"); + }); + + it('rejects an empty pasted address', async () => { + setParams(ctx, { site: { mode: 'url', value: '' } }); + + await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow("The 'Site' parameter is empty"); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('rejects an address that is not a valid URL', async () => { + setParams(ctx, { site: { mode: 'url', value: 'not a url' } }); + + await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow('The site address is not valid'); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it("resolves a pasted address via Graph's hostname:path site addressing", async () => { + setParams(ctx, { + site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' }, + }); + apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,g1,g2' }); + + const siteId = await resolveSiteId.call(ctx, 0); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/v1.0/sites/contoso.sharepoint.com:/sites/mysite', + {}, + { $select: 'id' }, + ); + expect(siteId).toBe('contoso.sharepoint.com,g1,g2'); + }); + + it('resolves the root site when the address has no path', async () => { + setParams(ctx, { site: { mode: 'url', value: 'https://contoso.sharepoint.com' } }); + apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,root' }); + + await resolveSiteId.call(ctx, 0); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/v1.0/sites/contoso.sharepoint.com', + {}, + { $select: 'id' }, + ); + }); + + it('caches the resolution per execution, across items, when the caller shares its cache', async () => { + setParams(ctx, { + site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' }, + }); + apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,g1,g2' }); + + // The caller hoists this once per run and passes the same instance for + // every item — that's what makes it a per-run cache, not the function itself. + const siteIdCache = new Map(); + await resolveSiteId.call(ctx, 0, siteIdCache); + await resolveSiteId.call(ctx, 1, siteIdCache); + + expect(apiRequest).toHaveBeenCalledTimes(1); + }); + + it('resolves again when no cache is passed, or a fresh one is', async () => { + const site = { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' }; + setParams(ctx, { site }); + apiRequest.mockResolvedValue({ id: 'contoso.sharepoint.com,g1,g2' }); + + await resolveSiteId.call(ctx, 0); + await resolveSiteId.call(ctx, 0, new Map()); + + expect(apiRequest).toHaveBeenCalledTimes(2); + }); + + it('reports a not-found address as a Site problem, not the generic message', async () => { + setParams(ctx, { + site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/ghost' }, + }); + apiRequest.mockRejectedValue( + new NodeApiError(mock(), { message: 'x' }, { httpCode: '404' }), + ); + + await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow('Site not found'); + }); + + it('lets other errors bubble up unchanged', async () => { + setParams(ctx, { + site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' }, + }); + apiRequest.mockRejectedValue( + new NodeApiError(mock(), { message: 'x' }, { httpCode: '500' }), + ); + + await expect(resolveSiteId.call(ctx, 0)).rejects.toThrow(NodeApiError); + }); + + it('works from a load-options context, for the library dropdown', async () => { + const loadOptionsCtx = mockDeep(); + loadOptionsCtx.getNode.mockReturnValue(mock()); + setParams(loadOptionsCtx, { site: { mode: 'id', value: 'contoso.sharepoint.com,g1,g2' } }); + + await expect(resolveSiteId.call(loadOptionsCtx, 0)).resolves.toBe( + 'contoso.sharepoint.com,g1,g2', + ); + }); + }); }); diff --git a/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/test/methods/listSearch.test.ts b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/test/methods/listSearch.test.ts new file mode 100644 index 00000000000..e16b425f0f0 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/ExcelSharePoint/test/methods/listSearch.test.ts @@ -0,0 +1,240 @@ +import type { ILoadOptionsFunctions, INode } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import type { Mock } from 'vitest'; +import type { DeepMockProxy } from 'vitest-mock-extended'; +import { mock, mockDeep } from 'vitest-mock-extended'; + +import { libraryRLC, siteRLC } from '../../descriptions/common.descriptions'; +import { SERVICE_PRINCIPAL_AUTH } from '../../helpers/constants'; +import { searchLibraries, searchSites } from '../../methods/listSearch'; +import { MicrosoftExcelSharePoint } from '../../MicrosoftExcelSharePoint.node'; +import * as transport from '../../transport'; +import type * as _importType0 from '../../transport'; + +// Real transport module except the network helper +vi.mock('../../transport', async () => { + const originalModule = await vi.importActual('../../transport'); + return { + ...originalModule, + microsoftApiRequest: vi.fn(), + }; +}); + +describe('Microsoft Excel (SharePoint) — listSearch', () => { + let ctx: DeepMockProxy; + const apiRequest = transport.microsoftApiRequest as Mock; + + const setParams = (params: Record) => { + ctx.getNodeParameter.mockImplementation( + (name: string, _itemIndex?: number, fallback?: unknown) => + (name in params ? params[name] : fallback) as never, + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = mockDeep(); + ctx.getNode.mockReturnValue(mock({ typeVersion: 1 })); + setParams({ authentication: 'microsoftOAuth2Api' }); + }); + + describe('searchSites', () => { + it('searches sites by name using the literal `search` parameter', async () => { + apiRequest.mockResolvedValue({ + value: [ + { id: 'site-b', displayName: 'Marketing B', webUrl: 'https://c.sharepoint.com/sites/b' }, + { id: 'site-a', displayName: 'Marketing A', webUrl: 'https://c.sharepoint.com/sites/a' }, + ], + }); + + const result = await searchSites.call(ctx, 'marketing'); + + // Graph quirk: `search`, not `$search` + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/v1.0/sites', + {}, + { search: 'marketing', $select: 'id,displayName,webUrl' }, + ); + // Kept in the API's order — no client-side sort + expect(result.results).toEqual([ + { name: 'Marketing B', value: 'site-b', url: 'https://c.sharepoint.com/sites/b' }, + { name: 'Marketing A', value: 'site-a', url: 'https://c.sharepoint.com/sites/a' }, + ]); + }); + + it('lists all sites when no search text is given', async () => { + apiRequest.mockResolvedValue({ value: [] }); + + await searchSites.call(ctx); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/v1.0/sites', + {}, + { search: '*', $select: 'id,displayName,webUrl' }, + ); + }); + + it('labels a site without a display name by its ID, and drops entries without one', async () => { + apiRequest.mockResolvedValue({ value: [{ id: 'bare-site' }, { displayName: 'No ID' }] }); + + const result = await searchSites.call(ctx); + + expect(result.results).toEqual([{ name: 'bare-site', value: 'bare-site', url: undefined }]); + }); + + it('hands back the next-page link and requests it exactly as returned', async () => { + const nextLink = 'https://graph.microsoft.com/v1.0/sites?search=%2A&$skiptoken=abc'; + apiRequest.mockResolvedValueOnce({ + value: [{ id: 's1', displayName: 'One' }], + '@odata.nextLink': nextLink, + }); + + const firstPage = await searchSites.call(ctx); + expect(firstPage.paginationToken).toBe(nextLink); + + apiRequest.mockResolvedValueOnce({ value: [{ id: 's2', displayName: 'Two' }] }); + const secondPage = await searchSites.call(ctx, undefined, nextLink); + + // The link is a complete address — passed through verbatim, never rebuilt + expect(apiRequest).toHaveBeenLastCalledWith('GET', '', {}, {}, nextLink); + expect(secondPage.results).toEqual([{ name: 'Two', value: 's2', url: undefined }]); + expect(secondPage.paginationToken).toBeUndefined(); + }); + + it('points app-only sign-ins without search rights at the other modes', async () => { + setParams({ authentication: SERVICE_PRINCIPAL_AUTH }); + apiRequest.mockRejectedValue( + new NodeApiError(mock(), { message: 'refused' }, { httpCode: '403' }), + ); + + await expect(searchSites.call(ctx)).rejects.toThrow('This app sign-in cannot search sites'); + }); + + it('keeps the permission-naming message for delegated refusals', async () => { + setParams({ authentication: 'microsoftOAuth2Api' }); + // Mirrors the transport's delegated 403 shape: microsoftApiRequest has + // already mapped the raw Graph error into a NodeApiError with the + // permission-naming message baked into `error.message` + apiRequest.mockRejectedValue( + new NodeApiError( + mock(), + { message: 'refused' }, + { + httpCode: '403', + message: 'the credential may be missing the Sites.Read.All permission', + }, + ), + ); + + await expect(searchSites.call(ctx)).rejects.toThrow(/Sites\.Read\.All/); + }); + + it('lets a non-403 failure bubble up unchanged', async () => { + setParams({ authentication: SERVICE_PRINCIPAL_AUTH }); + apiRequest.mockRejectedValue( + new NodeApiError(mock(), {}, { httpCode: '500', message: 'boom' }), + ); + + await expect(searchSites.call(ctx)).rejects.toThrow('boom'); + }); + }); + + describe('searchLibraries', () => { + it("lists the chosen site's document libraries", async () => { + setParams({ site: { mode: 'id', value: 'contoso.sharepoint.com,g1,g2' } }); + apiRequest.mockResolvedValue({ + value: [ + { id: 'b!drive1', name: 'Documents', webUrl: 'https://c.sharepoint.com/Documents' }, + { id: 'b!drive2', name: 'Reports' }, + ], + }); + + const result = await searchLibraries.call(ctx); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives', + {}, + { $select: 'id,name,webUrl' }, + ); + expect(result.results).toEqual([ + { name: 'Documents', value: 'b!drive1', url: 'https://c.sharepoint.com/Documents' }, + { name: 'Reports', value: 'b!drive2', url: undefined }, + ]); + }); + + it('resolves a pasted Site address before listing libraries', async () => { + setParams({ site: { mode: 'url', value: 'https://contoso.sharepoint.com/sites/mysite' } }); + apiRequest.mockImplementation(async (_method: string, resource: string) => + resource.startsWith('/v1.0/sites/contoso.sharepoint.com:') + ? { id: 'contoso.sharepoint.com,g1,g2' } + : { value: [{ id: 'b!drive1', name: 'Documents' }] }, + ); + + const result = await searchLibraries.call(ctx); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/v1.0/sites/contoso.sharepoint.com:/sites/mysite', + {}, + { $select: 'id' }, + ); + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/v1.0/sites/contoso.sharepoint.com%2Cg1%2Cg2/drives', + {}, + { $select: 'id,name,webUrl' }, + ); + expect(result.results).toEqual([{ name: 'Documents', value: 'b!drive1', url: undefined }]); + }); + + it('rejects when no Site has been chosen yet', async () => { + setParams({ site: { mode: 'id', value: '' } }); + + await expect(searchLibraries.call(ctx)).rejects.toThrow("The 'Site' parameter is empty"); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('hands back the next-page link and requests it exactly as returned', async () => { + setParams({ site: { mode: 'id', value: 'contoso.sharepoint.com,g1,g2' } }); + const nextLink = 'https://graph.microsoft.com/v1.0/sites/s/drives?$skiptoken=abc'; + apiRequest.mockResolvedValueOnce({ + value: [{ id: 'b!drive1', name: 'Documents' }], + '@odata.nextLink': nextLink, + }); + + const firstPage = await searchLibraries.call(ctx); + expect(firstPage.paginationToken).toBe(nextLink); + + apiRequest.mockResolvedValueOnce({ value: [{ id: 'b!drive2', name: 'Reports' }] }); + const secondPage = await searchLibraries.call(ctx, undefined, nextLink); + + expect(apiRequest).toHaveBeenLastCalledWith('GET', '', {}, {}, nextLink); + expect(secondPage.paginationToken).toBeUndefined(); + }); + }); + + describe('field shape', () => { + it('offers search first, with URL and ID modes alongside, for Site', () => { + expect(siteRLC.modes?.map((mode) => mode.name)).toEqual(['list', 'url', 'id']); + expect(siteRLC.modes?.[0].typeOptions?.searchListMethod).toBe('searchSites'); + expect(siteRLC.default).toEqual({ mode: 'list', value: '' }); + }); + + it('offers search first, with ID mode alongside, for Document Library', () => { + expect(libraryRLC.modes?.map((mode) => mode.name)).toEqual(['list', 'id']); + expect(libraryRLC.modes?.[0].typeOptions?.searchListMethod).toBe('searchLibraries'); + expect(libraryRLC.typeOptions?.loadOptionsDependsOn).toEqual(['site.value']); + expect(libraryRLC.default).toEqual({ mode: 'list', value: '' }); + }); + + it('is wired into the node as list-search methods', () => { + const node = new MicrosoftExcelSharePoint(); + + expect(node.methods?.listSearch?.searchSites).toBe(searchSites); + expect(node.methods?.listSearch?.searchLibraries).toBe(searchLibraries); + }); + }); +});