fix(Microsoft Excel Node): Resolve service principal target per input item (#34112)

This commit is contained in:
Csaba Tuncsik 2026-07-16 11:20:16 +02:00 committed by GitHub
parent bf3c2816c2
commit afc8a87a60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1205 additions and 47 deletions

View File

@ -48,6 +48,9 @@ describe('Microsoft Excel V2 - loadOptions', () => {
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
{ select: 'values' },
undefined,
undefined,
0,
);
expect(result).toEqual([
@ -79,6 +82,10 @@ describe('Microsoft Excel V2 - loadOptions', () => {
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ select: 'values' },
undefined,
undefined,
undefined,
0,
);
expect(result).toEqual([

View File

@ -0,0 +1,52 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import { credentials } from '../../../credentials';
// Pins the whole-batch rule: table:append sends ONE request-set for all input items and
// reads every param at item 0 — so even with per-item target expressions that differ,
// all four session requests go to item 0's (alice's) root. Bob's root is never mocked,
// so any request there fails (disableNetConnect); empty pendingMocks pins the exact set.
describe('Test MicrosoftExcelV2, Service Principal table => append uses item 0 target for the whole batch', () => {
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.get(
'/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/tables/%7B317CA469-7D1C-4A5D-9B0B-424444BF0336%7D/columns',
)
.reply(200, { value: [{ name: 'row' }] });
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.post(
'/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/createSession',
{
persistChanges: true,
},
)
.reply(200, { id: 'session-1' });
// Both items' rows land in ONE add call under alice's root.
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.matchHeader('workbook-session-id', 'session-1')
.post(
'/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/tables/%7B317CA469-7D1C-4A5D-9B0B-424444BF0336%7D/rows/add',
{ values: [['r1'], ['r2']] },
)
.reply(200, { index: 0, values: [['r1'], ['r2']] });
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.matchHeader('workbook-session-id', 'session-1')
.post(
'/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/closeSession',
{},
)
.reply(200);
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['append.sp.batch.workflow.json'],
customAssertions: () => expect(nock.pendingMocks()).toEqual([]),
});
});

View File

@ -0,0 +1,91 @@
{
"name": "microsoft excel 365 - table append (service principal, whole-batch item-0 target)",
"nodes": [
{
"parameters": {},
"id": "9a000000-0000-4000-8000-000000000001",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"data": [
{ "user": "alice@contoso.com", "row": "r1" },
{ "user": "bob@contoso.com", "row": "r2" }
]
},
"id": "9a000000-0000-4000-8000-000000000002",
"name": "Test Data",
"type": "n8n-nodes-testing.testData",
"typeVersion": 1,
"position": [200, 0]
},
{
"parameters": {
"authentication": "microsoftEntraServicePrincipalApi",
"userTarget": {
"__rl": true,
"value": "={{ $json.user }}",
"mode": "id"
},
"resource": "table",
"operation": "append",
"workbook": {
"__rl": true,
"value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I",
"mode": "id"
},
"worksheet": {
"__rl": true,
"value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}",
"mode": "id"
},
"table": {
"__rl": true,
"value": "{317CA469-7D1C-4A5D-9B0B-424444BF0336}",
"mode": "id"
},
"dataMode": "autoMap",
"options": {}
},
"id": "9a000000-0000-4000-8000-000000000003",
"name": "Microsoft Excel 365",
"type": "n8n-nodes-base.microsoftExcel",
"typeVersion": 2,
"position": [400, 0],
"credentials": {
"microsoftEntraServicePrincipalApi": {
"id": "1",
"name": "Microsoft Entra Service Principal account"
}
}
}
],
"pinData": {
"Microsoft Excel 365": [
{
"json": {
"row": "r1"
}
},
{
"json": {
"row": "r2"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [[{ "node": "Test Data", "type": "main", "index": 0 }]]
},
"Test Data": {
"main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]]
}
},
"active": false,
"settings": {},
"tags": []
}

View File

@ -0,0 +1,30 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import { credentials } from '../../../credentials';
// Drive-mode per-item resolution: with `Access As: Drive` the target id IS the drive,
// addressed as `/drives/{id}` (no `/drive` suffix). Two items with distinct drive-id
// expressions must each read from their own drive root. Consume-once + empty
// pendingMocks pin the exact request set.
describe('Test MicrosoftExcelV2, Service Principal worksheet => readRows per-item drive target', () => {
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.get(
'/drives/b!driveA/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange',
)
.reply(200, { values: [['row'], ['drive-a-row']] });
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.get(
'/drives/b!driveB/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange',
)
.reply(200, { values: [['row'], ['drive-b-row']] });
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['readRows.sp.drive.perItem.workflow.json'],
customAssertions: () => expect(nock.pendingMocks()).toEqual([]),
});
});

View File

@ -0,0 +1,83 @@
{
"name": "microsoft excel 365 - read rows (service principal, per-item drive target)",
"nodes": [
{
"parameters": {},
"id": "8a000000-0000-4000-8000-000000000001",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"data": [{ "drive": "b!driveA" }, { "drive": "b!driveB" }]
},
"id": "8a000000-0000-4000-8000-000000000002",
"name": "Test Data",
"type": "n8n-nodes-testing.testData",
"typeVersion": 1,
"position": [200, 0]
},
{
"parameters": {
"authentication": "microsoftEntraServicePrincipalApi",
"resourceTarget": "drive",
"driveTarget": {
"__rl": true,
"value": "={{ $json.drive }}",
"mode": "id"
},
"resource": "worksheet",
"operation": "readRows",
"workbook": {
"__rl": true,
"value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I",
"mode": "id"
},
"worksheet": {
"__rl": true,
"value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}",
"mode": "id"
},
"options": {}
},
"id": "8a000000-0000-4000-8000-000000000003",
"name": "Microsoft Excel 365",
"type": "n8n-nodes-base.microsoftExcel",
"typeVersion": 2,
"position": [400, 0],
"credentials": {
"microsoftEntraServicePrincipalApi": {
"id": "1",
"name": "Microsoft Entra Service Principal account"
}
}
}
],
"pinData": {
"Microsoft Excel 365": [
{
"json": {
"row": "drive-a-row"
}
},
{
"json": {
"row": "drive-b-row"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [[{ "node": "Test Data", "type": "main", "index": 0 }]]
},
"Test Data": {
"main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]]
}
},
"active": false,
"settings": {},
"tags": []
}

View File

@ -0,0 +1,30 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import { credentials } from '../../../credentials';
// The target RLC accepts expressions, so it must be resolved per input item: the Test
// Data node fans out to two users and each item's read must be rebased onto that item's
// `/users/{id}/drive` root (encoding pin preserved). Consume-once interceptors + empty
// pendingMocks pin the exact request set in both directions.
describe('Test MicrosoftExcelV2, Service Principal worksheet => readRows per-item target', () => {
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.get(
'/users/jane%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange',
)
.reply(200, { values: [['row'], ['jane-row']] });
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.get(
'/users/john%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange',
)
.reply(200, { values: [['row'], ['john-row']] });
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['readRows.sp.perItem.workflow.json'],
customAssertions: () => expect(nock.pendingMocks()).toEqual([]),
});
});

View File

@ -0,0 +1,82 @@
{
"name": "microsoft excel 365 - read rows (service principal, per-item target)",
"nodes": [
{
"parameters": {},
"id": "6a000000-0000-4000-8000-000000000001",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"data": [{ "user": "jane@contoso.com" }, { "user": "john@contoso.com" }]
},
"id": "6a000000-0000-4000-8000-000000000002",
"name": "Test Data",
"type": "n8n-nodes-testing.testData",
"typeVersion": 1,
"position": [200, 0]
},
{
"parameters": {
"authentication": "microsoftEntraServicePrincipalApi",
"userTarget": {
"__rl": true,
"value": "={{ $json.user }}",
"mode": "id"
},
"resource": "worksheet",
"operation": "readRows",
"workbook": {
"__rl": true,
"value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I",
"mode": "id"
},
"worksheet": {
"__rl": true,
"value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}",
"mode": "id"
},
"options": {}
},
"id": "6a000000-0000-4000-8000-000000000003",
"name": "Microsoft Excel 365",
"type": "n8n-nodes-base.microsoftExcel",
"typeVersion": 2,
"position": [400, 0],
"credentials": {
"microsoftEntraServicePrincipalApi": {
"id": "1",
"name": "Microsoft Entra Service Principal account"
}
}
}
],
"pinData": {
"Microsoft Excel 365": [
{
"json": {
"row": "jane-row"
}
},
{
"json": {
"row": "john-row"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [[{ "node": "Test Data", "type": "main", "index": 0 }]]
},
"Test Data": {
"main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]]
}
},
"active": false,
"settings": {},
"tags": []
}

View File

@ -0,0 +1,23 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import { credentials } from '../../../credentials';
// Per-item error isolation under continueOnFail: item 0's bogus target ("not a user"
// fails the shape validators) must produce an error item for item 0 only, while item 1
// still reads from its own user's drive. Only jane is mocked; consume-once + empty
// pendingMocks pin the exact request set.
describe('Test MicrosoftExcelV2, Service Principal worksheet => readRows fails only the bad item under continueOnFail', () => {
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.get(
'/users/jane%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange',
)
.reply(200, { values: [['row'], ['jane-row']] });
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['readRows.sp.perItemContinueOnFail.workflow.json'],
customAssertions: () => expect(nock.pendingMocks()).toEqual([]),
});
});

View File

@ -0,0 +1,83 @@
{
"name": "microsoft excel 365 - read rows (service principal, per-item target, continueOnFail)",
"nodes": [
{
"parameters": {},
"id": "7a000000-0000-4000-8000-000000000001",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"data": [{ "user": "not a user" }, { "user": "jane@contoso.com" }]
},
"id": "7a000000-0000-4000-8000-000000000002",
"name": "Test Data",
"type": "n8n-nodes-testing.testData",
"typeVersion": 1,
"position": [200, 0]
},
{
"parameters": {
"authentication": "microsoftEntraServicePrincipalApi",
"userTarget": {
"__rl": true,
"value": "={{ $json.user }}",
"mode": "id"
},
"resource": "worksheet",
"operation": "readRows",
"workbook": {
"__rl": true,
"value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I",
"mode": "id"
},
"worksheet": {
"__rl": true,
"value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}",
"mode": "id"
},
"options": {}
},
"id": "7a000000-0000-4000-8000-000000000003",
"name": "Microsoft Excel 365",
"type": "n8n-nodes-base.microsoftExcel",
"typeVersion": 2,
"position": [400, 0],
"onError": "continueRegularOutput",
"credentials": {
"microsoftEntraServicePrincipalApi": {
"id": "1",
"name": "Microsoft Entra Service Principal account"
}
}
}
],
"pinData": {
"Microsoft Excel 365": [
{
"json": {
"error": "The target ID is not valid"
}
},
{
"json": {
"row": "jane-row"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [[{ "node": "Test Data", "type": "main", "index": 0 }]]
},
"Test Data": {
"main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]]
}
},
"active": false,
"settings": {},
"tags": []
}

View File

@ -0,0 +1,63 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import { credentials } from '../../../credentials';
// Pins the whole-batch rule for worksheet:upsert: one request set for all input items and
// every request-level parameter (including the SP target) is read at item 0 — so even with
// per-item target expressions that differ, all three requests (usedRange read, append-range
// probe, final write) go to item 0's (alice's) root. Bob's root is never mocked, so any
// request there fails (disableNetConnect); empty pendingMocks pins the exact set.
describe('Test MicrosoftExcelV2, Service Principal worksheet => upsert uses item 0 target for the whole batch', () => {
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.get(
'/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange',
)
.reply(200, {
address: 'Sheet1!A1:B3',
values: [
['id', 'data'],
['1', 'old1'],
['2', 'old2'],
],
});
// Item 1 appends, so the node re-reads the used range (address only) — still under alice.
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.get(
'/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange',
)
.query({ select: 'address' })
.reply(200, { address: 'Sheet1!A1:B3' });
// Both items' rows (item 0's update + item 1's append) land in ONE write under alice's root.
nock('https://graph.microsoft.com/v1.0')
.matchHeader('Authorization', 'Bearer test-access-token')
.patch(
"/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/range(address='A1:B4')",
{
values: [
['id', 'data'],
['1', 'new1'],
['2', 'old2'],
['3', 'new3'],
],
},
)
.reply(200, {
values: [
['id', 'data'],
['1', 'new1'],
['2', 'old2'],
['3', 'new3'],
],
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['upsert.sp.batch.workflow.json'],
customAssertions: () => expect(nock.pendingMocks()).toEqual([]),
});
});

View File

@ -0,0 +1,89 @@
{
"name": "microsoft excel 365 - worksheet upsert (service principal, whole-batch item-0 target)",
"nodes": [
{
"parameters": {},
"id": "5a000000-0000-4000-8000-000000000001",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"data": [
{ "user": "alice@contoso.com", "id": "1", "data": "new1" },
{ "user": "bob@contoso.com", "id": "3", "data": "new3" }
]
},
"id": "5a000000-0000-4000-8000-000000000002",
"name": "Test Data",
"type": "n8n-nodes-testing.testData",
"typeVersion": 1,
"position": [200, 0]
},
{
"parameters": {
"authentication": "microsoftEntraServicePrincipalApi",
"userTarget": {
"__rl": true,
"value": "={{ $json.user }}",
"mode": "id"
},
"resource": "worksheet",
"operation": "upsert",
"workbook": {
"__rl": true,
"value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I",
"mode": "id"
},
"worksheet": {
"__rl": true,
"value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}",
"mode": "id"
},
"dataMode": "autoMap",
"columnToMatchOn": "id",
"options": {}
},
"id": "5a000000-0000-4000-8000-000000000003",
"name": "Microsoft Excel 365",
"type": "n8n-nodes-base.microsoftExcel",
"typeVersion": 2.1,
"position": [400, 0],
"credentials": {
"microsoftEntraServicePrincipalApi": {
"id": "1",
"name": "Microsoft Entra Service Principal account"
}
}
}
],
"pinData": {
"Microsoft Excel 365": [
{
"json": {
"id": "1",
"data": "new1"
}
},
{
"json": {
"id": "3",
"data": "new3"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [[{ "node": "Test Data", "type": "main", "index": 0 }]]
},
"Test Data": {
"main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]]
}
},
"active": false,
"settings": {},
"tags": []
}

View File

@ -7,6 +7,7 @@ import type {
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
@ -94,6 +95,9 @@ export async function execute(
{
select: 'address',
},
undefined,
undefined,
i,
);
range = address.split('!')[1];
} else {
@ -108,6 +112,10 @@ export async function execute(
address: range,
hasHeaders,
},
undefined,
undefined,
undefined,
i,
);
const executionData = this.helpers.constructExecutionMetaData(
@ -125,7 +133,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -159,6 +159,7 @@ export async function execute(
try {
// TODO: At some point it should be possible to use item dependent parameters.
// Is however important to then not make one separate request each.
// Whole-batch operation: one request set for all items; request-level params (including the SP target) are read at item 0.
const workbookId = this.getNodeParameter('workbook', 0, undefined, {
extractValue: true,
}) as string;
@ -179,6 +180,10 @@ export async function execute(
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
undefined,
undefined,
undefined,
0,
);
const columnsRow = columnsData.value.map((column: IDataObject) => column.name);
@ -242,6 +247,10 @@ export async function execute(
'POST',
`/drive/items/${workbookId}/workbook/createSession`,
{ persistChanges: true },
undefined,
undefined,
undefined,
0,
);
const responseData = await microsoftApiRequest.call(
this,
@ -251,6 +260,7 @@ export async function execute(
{},
'',
{ 'workbook-session-id': id },
0,
);
await microsoftApiRequest.call(
this,
@ -260,6 +270,7 @@ export async function execute(
{},
'',
{ 'workbook-session-id': id },
0,
);
const rawData = options.rawData as boolean;

View File

@ -7,6 +7,7 @@ import type {
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
@ -45,6 +46,11 @@ export async function execute(
this,
'POST',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/convertToRange`,
undefined,
undefined,
undefined,
undefined,
i,
);
const executionData = this.helpers.constructExecutionMetaData(
@ -62,7 +68,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -2,6 +2,7 @@ import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
@ -40,6 +41,11 @@ export async function execute(
this,
'DELETE',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}`,
undefined,
undefined,
undefined,
undefined,
i,
);
const executionData = this.helpers.constructExecutionMetaData(
@ -57,7 +63,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -7,6 +7,7 @@ import type {
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest, microsoftApiRequestAllItemsSkip } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
@ -129,6 +130,7 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
qs,
i,
);
} else {
qs.$top = this.getNodeParameter('limit', i);
@ -138,6 +140,9 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
qs,
undefined,
undefined,
i,
);
responseData = responseData.value;
}
@ -163,7 +168,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -7,6 +7,7 @@ import type {
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest, microsoftApiRequestAllItemsSkip } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
@ -147,6 +148,7 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows`,
{},
qs,
i,
);
} else {
const rowsQs = { ...qs };
@ -157,6 +159,9 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows`,
{},
rowsQs,
undefined,
undefined,
i,
);
responseData = responseData.value;
}
@ -171,6 +176,7 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
columnsQs,
i,
);
columns = (columns as IDataObject[]).map((column) => column.name);
@ -220,7 +226,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -9,6 +9,7 @@ import { NodeApiError } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequestAllItemsSkip } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
@ -95,6 +96,7 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows`,
{},
{},
i,
);
qs.$select = 'name';
@ -106,6 +108,7 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
qs,
i,
);
columns = columns.map((column: IDataObject) => column.name);
@ -155,7 +158,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -7,6 +7,7 @@ import type {
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC } from '../common.descriptions';
@ -63,6 +64,10 @@ export async function execute(
'POST',
`/drive/items/${workbookId}/workbook/createSession`,
{ persistChanges: true },
undefined,
undefined,
undefined,
i,
);
const responseData = await microsoftApiRequest.call(
this,
@ -72,6 +77,7 @@ export async function execute(
{},
'',
{ 'workbook-session-id': id },
i,
);
await microsoftApiRequest.call(
this,
@ -81,6 +87,7 @@ export async function execute(
{},
'',
{ 'workbook-session-id': id },
i,
);
if (Array.isArray(responseData)) {
@ -107,7 +114,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -3,6 +3,7 @@ import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC } from '../common.descriptions';
@ -29,7 +30,16 @@ export async function execute(
}) as string;
try {
await microsoftApiRequest.call(this, 'DELETE', `/drive/items/${workbookId}`);
await microsoftApiRequest.call(
this,
'DELETE',
`/drive/items/${workbookId}`,
undefined,
undefined,
undefined,
undefined,
i,
);
} catch (error) {
if (error?.description.includes('Lock token does not match existing lock')) {
const errorDescription =
@ -70,7 +80,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -8,6 +8,7 @@ import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import {
getExcelCredentialType,
microsoftApiRequest,
@ -100,6 +101,7 @@ export async function execute(
"/drive/root/search(q='.xlsx')",
{},
qs,
i,
);
} else {
qs.$top = this.getNodeParameter('limit', i);
@ -109,6 +111,9 @@ export async function execute(
"/drive/root/search(q='.xlsx')",
{},
qs,
undefined,
undefined,
i,
);
responseData = responseData.value;
}
@ -137,7 +142,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -145,6 +145,7 @@ export async function execute(
const nodeVersion = this.getNode().typeVersion;
// Whole-batch operation: one request set for all items; request-level params (including the SP target) are read at item 0.
const workbookId = this.getNodeParameter('workbook', 0, undefined, {
extractValue: true,
}) as string;
@ -159,6 +160,11 @@ export async function execute(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
undefined,
undefined,
undefined,
0,
);
let values: string[][] = [];
@ -254,6 +260,10 @@ export async function execute(
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values },
undefined,
undefined,
undefined,
0,
);
const rawData = this.getNodeParameter('options.rawData', 0, false) as boolean;

View File

@ -2,6 +2,7 @@ import type { INodeExecutionData, IExecuteFunctions, INodeProperties } from 'n8n
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
@ -88,6 +89,10 @@ export async function execute(
'POST',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range/clear`,
{ applyTo },
undefined,
undefined,
undefined,
i,
);
} else {
const range = this.getNodeParameter('range', i, '') as string;
@ -96,6 +101,10 @@ export async function execute(
'POST',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')/clear`,
{ applyTo },
undefined,
undefined,
undefined,
i,
);
}
@ -114,7 +123,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -2,6 +2,7 @@ import type { INodeExecutionData, IExecuteFunctions, INodeProperties } from 'n8n
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
@ -36,6 +37,11 @@ export async function execute(
this,
'DELETE',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}`,
undefined,
undefined,
undefined,
undefined,
i,
);
const executionData = this.helpers.constructExecutionMetaData(
@ -53,7 +59,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -7,6 +7,7 @@ import type {
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import { microsoftApiRequest, microsoftApiRequestAllItems } from '../../transport';
import { workbookRLC } from '../common.descriptions';
@ -90,6 +91,7 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets`,
{},
qs,
i,
);
} else {
qs.$top = this.getNodeParameter('limit', i);
@ -99,6 +101,9 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets`,
{},
qs,
undefined,
undefined,
i,
);
responseData = responseData.value;
}
@ -117,7 +122,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -7,6 +7,7 @@ import type {
import { updateDisplayOptions } from '@utils/utilities';
import { stampItemIndexOnError } from '../../../../GenericFunctions';
import type { ExcelResponse } from '../../helpers/interfaces';
import { checkRange, prepareOutput } from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';
@ -159,6 +160,9 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{},
qs,
undefined,
undefined,
i,
);
} else {
responseData = await microsoftApiRequest.call(
@ -167,6 +171,9 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
{},
qs,
undefined,
undefined,
i,
);
}
@ -199,7 +206,8 @@ export async function execute(
returnData.push(...executionErrorData);
continue;
}
throw error;
// A NodeError from the transport may be missing the itemIndex, add it
throw stampItemIndexOnError(error, i);
}
}

View File

@ -252,6 +252,7 @@ export async function execute(
qs.$select = options.fields;
}
// Whole-batch operation: every param (including the SP target) is read at item 0.
const workbookId = this.getNodeParameter('workbook', 0, undefined, {
extractValue: true,
}) as string;
@ -272,6 +273,11 @@ export async function execute(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
undefined,
undefined,
undefined,
undefined,
0,
);
}
@ -288,6 +294,9 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
query,
undefined,
undefined,
0,
);
range = (worksheetData.address as string).split('!')[1];
@ -305,6 +314,9 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values },
qs,
undefined,
undefined,
0,
);
returnData.push(
@ -361,6 +373,10 @@ export async function execute(
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values: updateSummary.updatedData },
undefined,
undefined,
undefined,
0,
);
const { updatedRows } = updateSummary;

View File

@ -204,6 +204,7 @@ export async function execute(
const nodeVersion = this.getNode().typeVersion;
try {
// Whole-batch operation: every param (including the SP target) is read at item 0.
const workbookId = this.getNodeParameter('workbook', 0, undefined, {
extractValue: true,
}) as string;
@ -224,6 +225,11 @@ export async function execute(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
undefined,
undefined,
undefined,
undefined,
0,
);
}
@ -240,6 +246,9 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
query,
undefined,
undefined,
0,
);
range = (worksheetData.address as string).split('!')[1];
@ -256,6 +265,10 @@ export async function execute(
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values },
undefined,
undefined,
undefined,
0,
);
}
@ -351,6 +364,9 @@ export async function execute(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
{ select: 'address' },
undefined,
undefined,
0,
);
const usedRange = parseAddress(address as string);
@ -366,6 +382,10 @@ export async function execute(
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values: updateSummary.updatedData },
undefined,
undefined,
undefined,
0,
);
const { updatedRows } = updateSummary;

View File

@ -45,7 +45,8 @@ export const userTargetRLC: INodeProperties = {
hint: 'The user principal name (UPN) or object ID of the user whose drive holds the workbooks',
},
],
description: 'The user whose drive the Service Principal should act on',
description:
"The user whose drive the Service Principal should act on. Evaluated per input item — an expression can target a different user for each item. Operations that write all rows in one request (append, update, upsert) use the first item's target.",
displayOptions: {
show: {
authentication: ['microsoftEntraServicePrincipalApi'],
@ -69,7 +70,8 @@ export const driveTargetRLC: INodeProperties = {
hint: "The drive's own ID (looks like `b!…`), not a file or folder ID. Get it from `GET /users/{upn}/drive` (the `id` field).",
},
],
description: 'The drive the Service Principal should act on',
description:
"The drive the Service Principal should act on. Evaluated per input item — an expression can target a different drive for each item. Operations that write all rows in one request (append, update, upsert) use the first item's target.",
displayOptions: {
show: {
authentication: ['microsoftEntraServicePrincipalApi'],

View File

@ -8,6 +8,9 @@ import { NodeOperationError } from 'n8n-workflow';
import { getExcelCredentialType, microsoftApiRequest } from '../transport';
// listSearch context throughout this file: the transport's trailing `0` is its
// fallback read (getNodeParameter's 2nd arg here is a fallback, not an item index).
export async function searchWorkbooks(
this: ILoadOptionsFunctions,
filter?: string,
@ -39,6 +42,8 @@ export async function searchWorkbooks(
undefined,
undefined,
paginationToken, // paginationToken contains the full URL
undefined,
0,
);
} else {
response = await microsoftApiRequest.call(
@ -50,6 +55,9 @@ export async function searchWorkbooks(
select: 'id,name,webUrl',
$top: 100,
},
undefined,
undefined,
0,
);
}
@ -98,6 +106,9 @@ export async function getWorksheetsList(
{
select: 'id,name',
},
undefined,
undefined,
0,
);
return {
@ -133,6 +144,10 @@ export async function getWorksheetTables(
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables`,
undefined,
undefined,
undefined,
undefined,
0,
);
const results: INodeListSearchItems[] = [];
@ -149,6 +164,9 @@ export async function getWorksheetTables(
{
select: 'address',
},
undefined,
undefined,
0,
);
const [sheetName, sheetRange] = address.split('!' as string);

View File

@ -3,6 +3,9 @@ import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n
import { microsoftApiRequest } from '../transport';
import { parseAddress } from '../helpers/utils';
// loadOptions context throughout this file: the transport's trailing `0` is its
// fallback read (getNodeParameter's 2nd arg here is a fallback, not an item index).
export async function getWorksheetColumnRow(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
@ -24,6 +27,9 @@ export async function getWorksheetColumnRow(
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
{ select: 'values' },
undefined,
undefined,
0,
);
columns = worksheetData.values[0] as string[];
@ -36,6 +42,10 @@ export async function getWorksheetColumnRow(
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ select: 'values' },
undefined,
undefined,
undefined,
0,
);
columns = worksheetData.values[0] as string[];
@ -79,6 +89,10 @@ export async function getTableColumns(
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
undefined,
undefined,
undefined,
0,
);
return (response.value as IDataObject[]).map((column) => ({

View File

@ -9,12 +9,14 @@ import {
import type { Mock, Mocked } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import * as readRows from '../../actions/worksheet/readRows.operation';
import { searchWorkbooks } from '../../methods/listSearch';
import {
driveEndpoint,
getExcelCredentialType,
getServicePrincipalResourceRoot,
microsoftApiRequest,
microsoftApiRequestAllItemsSkip,
resolveScopeRoot,
validateResourceTargetId,
} from '../../transport/index';
@ -58,7 +60,16 @@ describe('Microsoft Excel Transport', () => {
graphApiBaseUrl: 'https://graph.microsoft.us',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
@ -80,7 +91,16 @@ describe('Microsoft Excel Transport', () => {
graphApiBaseUrl: '',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
@ -101,7 +121,16 @@ describe('Microsoft Excel Transport', () => {
},
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
@ -123,7 +152,16 @@ describe('Microsoft Excel Transport', () => {
graphApiBaseUrl: 'https://graph.microsoft.com/',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
@ -145,7 +183,16 @@ describe('Microsoft Excel Transport', () => {
graphApiBaseUrl: 'https://graph.microsoft.com///',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
@ -167,7 +214,16 @@ describe('Microsoft Excel Transport', () => {
graphApiBaseUrl: 'https://graph.microsoft.us',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
@ -189,7 +245,16 @@ describe('Microsoft Excel Transport', () => {
graphApiBaseUrl: 'https://dod-graph.microsoft.us',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
@ -211,7 +276,16 @@ describe('Microsoft Excel Transport', () => {
graphApiBaseUrl: 'https://microsoftgraph.chinacloudapi.cn',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
@ -235,7 +309,16 @@ describe('Microsoft Excel Transport', () => {
it('should use microsoftExcelOAuth2Api when authentication is not set (backward compatibility)', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue(undefined);
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftExcelOAuth2Api');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
@ -247,7 +330,16 @@ describe('Microsoft Excel Transport', () => {
it('should use microsoftExcelOAuth2Api when explicitly selected', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftExcelOAuth2Api');
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftExcelOAuth2Api');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
@ -259,7 +351,16 @@ describe('Microsoft Excel Transport', () => {
it('should use microsoftOAuth2Api when the generic credential is selected', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftOAuth2Api');
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftOAuth2Api');
expect(mockRequestOAuth2).toHaveBeenCalledWith('microsoftOAuth2Api', expect.anything());
@ -268,7 +369,16 @@ describe('Microsoft Excel Transport', () => {
it('should resolve the credential name from the authentication parameter at index 0', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftOAuth2Api');
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockExecuteFunctions.getNodeParameter).toHaveBeenCalledWith('authentication', 0);
});
@ -276,7 +386,16 @@ describe('Microsoft Excel Transport', () => {
it('should honor graphApiBaseUrl from the generic credential (sovereign cloud)', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftOAuth2Api');
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftOAuth2Api');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
@ -386,7 +505,16 @@ describe('Microsoft Excel Transport', () => {
});
it('routes through requestWithAuthentication, never requestOAuth2', async () => {
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE);
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
SCOPED_RESOURCE,
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith(
'microsoftEntraServicePrincipalApi',
@ -399,7 +527,16 @@ describe('Microsoft Excel Transport', () => {
});
it('rebases /me onto /users/{id}/drive for the user target', async () => {
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE);
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
SCOPED_RESOURCE,
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'microsoftEntraServicePrincipalApi',
@ -421,7 +558,16 @@ describe('Microsoft Excel Transport', () => {
(name in params ? params[name as string] : fallback) as never,
);
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE);
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
SCOPED_RESOURCE,
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'microsoftEntraServicePrincipalApi',
@ -434,7 +580,16 @@ describe('Microsoft Excel Transport', () => {
it('rebases onto /drives/{id} for the drive target (no double /drive)', async () => {
setSpParams({ resourceTarget: 'drive', driveTarget: { value: 'b!abc-123_XYZ' } });
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE);
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
SCOPED_RESOURCE,
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'microsoftEntraServicePrincipalApi',
@ -449,7 +604,16 @@ describe('Microsoft Excel Transport', () => {
graphApiBaseUrl: 'https://graph.microsoft.us',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE);
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
SCOPED_RESOURCE,
undefined,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'microsoftEntraServicePrincipalApi',
@ -470,6 +634,8 @@ describe('Microsoft Excel Transport', () => {
{},
{},
nextLink,
undefined,
0,
);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
@ -478,9 +644,99 @@ describe('Microsoft Excel Transport', () => {
);
});
// The target RLC accepts expressions, so execute call sites pass the loop index
// and the transport must resolve the target at exactly that index.
const setSpParamsPerItem = () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((name, itemIndex, fallback) => {
if (name === 'authentication') return 'microsoftEntraServicePrincipalApi' as never;
if (name === 'resourceTarget') return 'user' as never;
if (name === 'userTarget')
return { value: itemIndex === 1 ? 'john@contoso.com' : 'jane@contoso.com' } as never;
return fallback as never;
});
};
it('resolves the target at the passed itemIndex', async () => {
setSpParamsPerItem();
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
SCOPED_RESOURCE,
undefined,
undefined,
undefined,
undefined,
0,
);
await microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
SCOPED_RESOURCE,
undefined,
undefined,
undefined,
undefined,
1,
);
expect(mockRequestWithAuthentication).toHaveBeenNthCalledWith(
1,
'microsoftEntraServicePrincipalApi',
expect.objectContaining({
uri: `${baseUrl}/v1.0/users/jane%40contoso.com/drive/items/WB/workbook/worksheets/WS/usedRange`,
}),
);
expect(mockRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'microsoftEntraServicePrincipalApi',
expect.objectContaining({
uri: `${baseUrl}/v1.0/users/john%40contoso.com/drive/items/WB/workbook/worksheets/WS/usedRange`,
}),
);
});
it('keeps the passed itemIndex root on every $skip page (AllItemsSkip)', async () => {
setSpParamsPerItem();
mockRequestWithAuthentication
.mockResolvedValueOnce({ value: [{ id: 'row-1' }] })
.mockResolvedValueOnce({ value: [] });
await microsoftApiRequestAllItemsSkip.call(
mockExecuteFunctions,
'value',
'GET',
SCOPED_RESOURCE,
{},
{},
1,
);
const johnUri = `${baseUrl}/v1.0/users/john%40contoso.com/drive/items/WB/workbook/worksheets/WS/usedRange`;
expect(mockRequestWithAuthentication).toHaveBeenNthCalledWith(
1,
'microsoftEntraServicePrincipalApi',
expect.objectContaining({ uri: johnUri }),
);
expect(mockRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'microsoftEntraServicePrincipalApi',
expect.objectContaining({ uri: johnUri }),
);
});
it('throws OperationalError when a scoped resource does not start with /drive', async () => {
await expect(
microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'),
microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
'/workbooks',
undefined,
undefined,
undefined,
undefined,
0,
),
).rejects.toThrow(OperationalError);
expect(mockRequestWithAuthentication).not.toHaveBeenCalled();
});
@ -489,7 +745,16 @@ describe('Microsoft Excel Transport', () => {
mockRequestWithAuthentication.mockRejectedValue(new Error('boom'));
await expect(
microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE),
microsoftApiRequest.call(
mockExecuteFunctions,
'GET',
SCOPED_RESOURCE,
undefined,
undefined,
undefined,
undefined,
0,
),
).rejects.toThrow(NodeApiError);
});
@ -501,6 +766,10 @@ describe('Microsoft Excel Transport', () => {
'PATCH',
"/drive/items/WB/workbook/worksheets/WS/range(address='A1:A1')",
body,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
@ -521,6 +790,10 @@ describe('Microsoft Excel Transport', () => {
'POST',
'/drive/items/WB/workbook/worksheets/add',
body,
undefined,
undefined,
undefined,
0,
);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
@ -588,7 +861,7 @@ describe('Microsoft Excel Transport', () => {
it('returns undefined for the OAuth2 credential', () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftExcelOAuth2Api');
expect(resolveScopeRoot.call(mockExecuteFunctions)).toBeUndefined();
expect(resolveScopeRoot.call(mockExecuteFunctions, 0)).toBeUndefined();
});
it('resolves the user root for the Service Principal credential', () => {
@ -601,7 +874,7 @@ describe('Microsoft Excel Transport', () => {
(name) => params[name as string] as never,
);
expect(resolveScopeRoot.call(mockExecuteFunctions)).toBe('/users/jane%40contoso.com');
expect(resolveScopeRoot.call(mockExecuteFunctions, 0)).toBe('/users/jane%40contoso.com');
});
it('falls back to the user target when resourceTarget is unpersisted', () => {
@ -615,7 +888,43 @@ describe('Microsoft Excel Transport', () => {
(name in params ? params[name as string] : fallback) as never,
);
expect(resolveScopeRoot.call(mockExecuteFunctions)).toBe('/users/jane%40contoso.com');
expect(resolveScopeRoot.call(mockExecuteFunctions, 0)).toBe('/users/jane%40contoso.com');
});
});
describe('execute error attribution', () => {
it('stamps context.itemIndex on a NodeError from the failing item', async () => {
const mockRequestWithAuthentication = vi
.fn()
.mockResolvedValue({ values: [['row'], ['jane-row']] });
mockExecuteFunctions.helpers.requestWithAuthentication = mockRequestWithAuthentication;
(mockExecuteFunctions.helpers.constructExecutionMetaData as Mock).mockReturnValue([]);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
mockExecuteFunctions.getCredentials.mockResolvedValue({ graphApiBaseUrl: '' });
mockExecuteFunctions.getNodeParameter.mockImplementation((name, itemIndex, fallback) => {
const params: Record<string, unknown> = {
authentication: 'microsoftEntraServicePrincipalApi',
resourceTarget: 'user',
workbook: 'WB',
worksheet: 'WS',
};
if (name === 'userTarget')
return { value: itemIndex === 1 ? 'not a user' : 'jane@contoso.com' } as never;
return (name in params ? params[name as string] : fallback) as never;
});
let caught: unknown;
try {
await readRows.execute.call(mockExecuteFunctions, [{ json: {} }, { json: {} }]);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(NodeOperationError);
expect((caught as NodeOperationError).message).toBe('The target ID is not valid');
expect((caught as NodeOperationError).context.itemIndex).toBe(1);
// Item 0 resolved and requested normally before item 1 failed validation.
expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -86,12 +86,14 @@ export function driveEndpoint(root: string): string {
/**
* App-only Graph scope root (`/users/{id}` or `/drives/{id}`), or `undefined` for OAuth2 (`/me`).
* The `|| 'user'` and `''` fallbacks cover load-options, where the 2nd getNodeParameter arg is the
* fallback (not an itemIndex) an unpersisted target then coalesces to the "target ID required" error.
* The target RLC accepts expressions and is resolved per item: execute call sites pass the loop's
* item index; load-options call sites pass a literal 0 there the 2nd getNodeParameter arg is the
* fallback (not an itemIndex), which the `|| 'user'` and `''` fallbacks cover, so an unpersisted
* target coalesces to the "target ID required" error.
*/
export function resolveScopeRoot(
this: IExecuteFunctions | ILoadOptionsFunctions,
itemIndex = 0,
itemIndex: number,
): string | undefined {
if (getExcelCredentialType.call(this) !== 'microsoftEntraServicePrincipalApi') {
return undefined;
@ -105,14 +107,19 @@ export function resolveScopeRoot(
return getServicePrincipalResourceRoot(target, id, this.getNode());
}
// `itemIndex` is REQUIRED so the compiler enforces the per-item contract: execute
// call sites pass the loop index (batch ops pass a literal 0, matching their item-0
// param reads); loadOptions/listSearch call sites pass a literal 0, where
// `getNodeParameter`'s 2nd arg is a fallback, not an index.
export async function microsoftApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
resource: string,
body: any = {},
qs: IDataObject = {},
uri?: string,
uri: string | undefined,
headers: IDataObject = {},
itemIndex: number,
): Promise<any> {
const credentialType = getExcelCredentialType.call(this);
const isServicePrincipal = credentialType === 'microsoftEntraServicePrincipalApi';
@ -124,9 +131,9 @@ export async function microsoftApiRequest(
).replace(/\/+$/, '');
let uriToUse = uri || `${baseUrl}/v1.0/me${resource}`;
// App-only has no `/me`; rebase page-1 onto the user/drive root. Absolute `@odata.nextLink` pages pass through.
// App-only has no `/me`; rebase page-1 onto the item's user/drive root. Absolute `@odata.nextLink` pages pass through.
if (!uri && isServicePrincipal) {
const driveScopeRoot = resolveScopeRoot.call(this);
const driveScopeRoot = resolveScopeRoot.call(this, itemIndex);
if (driveScopeRoot) {
// Programmer error, not user input: every Excel resource is `/drive`-rooted.
if (!resource.startsWith('/drive')) {
@ -159,6 +166,7 @@ export async function microsoftApiRequest(
}
return await this.helpers.requestOAuth2.call(this, credentialType, options);
} catch (error) {
// The operation's catch stamps the failing item's index.
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
@ -170,6 +178,7 @@ export async function microsoftApiRequestAllItems(
endpoint: string,
body: any = {},
query: IDataObject = {},
itemIndex: number,
): Promise<any> {
const returnData: IDataObject[] = [];
@ -178,7 +187,16 @@ export async function microsoftApiRequestAllItems(
query.$top = 100;
do {
responseData = await microsoftApiRequest.call(this, method, endpoint, body, query, uri);
responseData = await microsoftApiRequest.call(
this,
method,
endpoint,
body,
query,
uri,
undefined,
itemIndex,
);
uri = responseData['@odata.nextLink'];
if (uri?.includes('$top')) {
delete query.$top;
@ -196,6 +214,7 @@ export async function microsoftApiRequestAllItemsSkip(
endpoint: string,
body: any = {},
query: IDataObject = {},
itemIndex: number,
): Promise<any> {
const returnData: IDataObject[] = [];
@ -204,7 +223,17 @@ export async function microsoftApiRequestAllItemsSkip(
query.$skip = 0;
do {
responseData = await microsoftApiRequest.call(this, method, endpoint, body, query);
// Every `$skip` page re-issues the scoped relative URL, re-resolved at the same index.
responseData = await microsoftApiRequest.call(
this,
method,
endpoint,
body,
query,
undefined,
undefined,
itemIndex,
);
query.$skip += query.$top;
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
} while (responseData.value.length !== 0);