feat(Google Workspace Admin Node): Google Admin Node Overhaul implementation (#12271)

Co-authored-by: knowa <github@libertyunion.org>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
Stanimira Rikova
2025-05-02 19:27:08 +03:00
committed by GitHub
parent bd258be052
commit 8a30c35c33
44 changed files with 5452 additions and 454 deletions

View File

@@ -0,0 +1,538 @@
import type { ILoadOptionsFunctions, IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import nock from 'nock';
import { returnData } from '../../../E2eTest/mock';
import { googleApiRequest, googleApiRequestAllItems } from '../GenericFunctions';
import { GSuiteAdmin } from '../GSuiteAdmin.node';
jest.mock('../GenericFunctions', () => ({
getGoogleAuth: jest.fn().mockImplementation(() => ({
oauth2Client: {
setCredentials: jest.fn(),
getAccessToken: jest.fn().mockResolvedValue('mock-access-token'),
},
})),
googleApiRequest: jest.fn(),
googleApiRequestAllItems: jest.fn(),
}));
const node = new GSuiteAdmin();
const mockThis = {
getNode: () => ({
name: 'Google Workspace Admin',
parameters: {},
}),
helpers: {
httpRequestWithAuthentication: jest.fn(),
returnJsonArray: (data: any) => data,
constructExecutionMetaData: (data: any) => data,
},
continueOnFail: () => false,
getNodeParameter: jest.fn((name: string) => {
if (name === 'limit') return 50;
return undefined;
}),
} as unknown as ILoadOptionsFunctions;
describe('GSuiteAdmin Node - loadOptions', () => {
beforeEach(() => {
jest.clearAllMocks();
nock.cleanAll();
nock.disableNetConnect();
});
describe('getDomains', () => {
it('should return a list of domains', async () => {
(googleApiRequestAllItems as jest.Mock).mockResolvedValue([
{ domainName: 'example.com' },
{ domainName: 'test.com' },
]);
const result = await node.methods.loadOptions.getDomains.call(mockThis);
expect(result).toEqual([
{ name: 'example.com', value: 'example.com' },
{ name: 'test.com', value: 'test.com' },
]);
});
});
describe('getSchemas', () => {
it('should return a list of schemas', async () => {
(googleApiRequestAllItems as jest.Mock).mockResolvedValue([
{ displayName: 'Employee Info', schemaName: 'EmployeeSchema' },
{ displayName: '', schemaName: 'CustomSchema' },
]);
const result = await node.methods.loadOptions.getSchemas.call(mockThis);
expect(result).toEqual([
{ name: 'Employee Info', value: 'EmployeeSchema' },
{ name: 'CustomSchema', value: 'CustomSchema' },
]);
});
it('should correctly iterate over schemas and return expected values', async () => {
const schemas = [
{ displayName: 'Employee Info', schemaName: 'EmployeeSchema' },
{ displayName: 'Custom Schema', schemaName: 'CustomSchema' },
];
const result = schemas.map((schema) => ({
name: schema.displayName,
value: schema.schemaName,
}));
expect(result).toEqual([
{ name: 'Employee Info', value: 'EmployeeSchema' },
{ name: 'Custom Schema', value: 'CustomSchema' },
]);
});
});
describe('getOrgUnits', () => {
it('should return a list of organizational units', async () => {
(googleApiRequest as jest.Mock).mockResolvedValue({
organizationUnits: [
{ name: 'Engineering', orgUnitPath: '/engineering' },
{ name: 'HR', orgUnitPath: '/hr' },
],
});
const result = await node.methods.loadOptions.getOrgUnits.call(mockThis);
expect(result).toEqual([
{ name: 'Engineering', value: '/engineering' },
{ name: 'HR', value: '/hr' },
]);
});
});
});
describe('GSuiteAdmin Node - logic coverage', () => {
it('should apply all filters correctly into qs', () => {
const filter = {
customer: 'my_customer',
domain: 'example.com',
query: 'name:admin',
userId: 'user@example.com',
showDeleted: true,
};
const sort = {
sortRules: { orderBy: 'email', sortOrder: 'ASCENDING' },
};
const qs: Record<string, any> = {};
if (filter.customer) qs.customer = filter.customer;
if (filter.domain) qs.domain = filter.domain;
if (filter.query) {
const query = filter.query.trim();
const regex = /^(name|email):\S+$/;
if (!regex.test(query)) {
throw new NodeOperationError(
mockThis.getNode(),
'Invalid query format. Query must follow the format "displayName:<value>" or "email:<value>".',
);
}
qs.query = query;
}
if (filter.userId) qs.userId = filter.userId;
if (filter.showDeleted) qs.showDeleted = 'true';
if (sort.sortRules) {
const { orderBy, sortOrder } = sort.sortRules;
if (orderBy) qs.orderBy = orderBy;
if (sortOrder) qs.sortOrder = sortOrder;
}
expect(qs).toEqual({
customer: 'my_customer',
domain: 'example.com',
query: 'name:admin',
userId: 'user@example.com',
showDeleted: 'true',
orderBy: 'email',
sortOrder: 'ASCENDING',
});
});
it('should throw an error for invalid query format', () => {
const filter = {
query: 'invalidQuery',
};
const qs: Record<string, any> = {};
expect(() => {
if (filter.query) {
const query = filter.query.trim();
const regex = /^(name|email):\S+$/;
if (!regex.test(query)) {
throw new NodeOperationError(
mockThis.getNode(),
'Invalid query format. Query must follow the format "displayName:<value>" or "email:<value>".',
);
}
qs.query = query;
}
}).toThrow(
'Invalid query format. Query must follow the format "displayName:<value>" or "email:<value>".',
);
});
it('should assign my_customer when customer is not defined', () => {
const qs: Record<string, any> = {};
if (!qs.customer) qs.customer = 'my_customer';
expect(qs.customer).toBe('my_customer');
});
it('should throw an error if username is empty', () => {
const mock = { getNode: () => ({}) } as IExecuteFunctions;
expect(() => {
const username = '';
if (!username) {
throw new NodeOperationError(mock.getNode(), "The parameter 'Username' is empty", {
itemIndex: 0,
description: "Please fill in the 'Username' parameter to create the user",
});
}
}).toThrow("The parameter 'Username' is empty");
});
it('should set phones, emails, roles, and custom fields', () => {
const additionalFields = {
phoneUi: { phoneValues: [{ type: 'work', value: '123' }] },
emailUi: { emailValues: [{ address: 'test@example.com', type: 'home' }] },
roles: ['superAdmin', 'groupsAdmin'],
customFields: {
fieldValues: [{ schemaName: 'CustomSchema', fieldName: 'customField', value: 'abc' }],
},
};
const body: Record<string, any> = {};
if (additionalFields.phoneUi) {
body.phones = additionalFields.phoneUi.phoneValues;
}
if (additionalFields.emailUi) {
body.emails = additionalFields.emailUi.emailValues;
}
if (additionalFields.roles) {
const roles = additionalFields.roles;
body.roles = {
superAdmin: roles.includes('superAdmin'),
groupsAdmin: roles.includes('groupsAdmin'),
groupsReader: false,
groupsEditor: false,
userManagement: false,
helpDeskAdmin: false,
servicesAdmin: false,
inventoryReportingAdmin: false,
storageAdmin: false,
directorySyncAdmin: false,
mobileAdmin: false,
};
}
if (additionalFields.customFields) {
const customSchemas: Record<string, any> = {};
for (const field of additionalFields.customFields.fieldValues) {
if (
!field.schemaName ||
!field.fieldName ||
field.value === undefined ||
field.value === ''
) {
continue;
}
if (!customSchemas[field.schemaName]) customSchemas[field.schemaName] = {};
customSchemas[field.schemaName][field.fieldName] = field.value;
}
if (Object.keys(customSchemas).length > 0) {
body.customSchemas = customSchemas;
}
}
expect(body).toEqual({
phones: [{ type: 'work', value: '123' }],
emails: [{ address: 'test@example.com', type: 'home' }],
roles: {
superAdmin: true,
groupsAdmin: true,
groupsReader: false,
groupsEditor: false,
userManagement: false,
helpDeskAdmin: false,
servicesAdmin: false,
inventoryReportingAdmin: false,
storageAdmin: false,
directorySyncAdmin: false,
mobileAdmin: false,
},
customSchemas: {
CustomSchema: { customField: 'abc' },
},
});
});
it('should set customFieldMask and fields if projection is custom and output is select', () => {
const projection = 'custom';
const output = 'select';
const fields = ['primaryEmail'];
const qs: Record<string, any> = {
customFieldMask: ['Custom1', 'Custom2'],
};
if (projection === 'custom' && qs.customFieldMask) {
qs.customFieldMask = (qs.customFieldMask as string[]).join(',');
}
if (output === 'select') {
if (!fields.includes('id')) fields.push('id');
qs.fields = fields.join(',');
}
expect(qs).toEqual({
customFieldMask: 'Custom1,Custom2',
fields: 'primaryEmail,id',
});
});
it('should set fields for user getAll when returnAll is false', () => {
const qs: Record<string, any> = {};
const returnAll = false;
const fields = ['primaryEmail'];
const output = 'select';
const projection = 'custom';
qs.customFieldMask = ['Custom1', 'Custom2'];
if (projection === 'custom' && qs.customFieldMask) {
qs.customFieldMask = (qs.customFieldMask as string[]).join(',');
}
if (output === 'select') {
if (!fields.includes('id')) fields.push('id');
qs.fields = `users(${fields.join(',')})`;
}
if (!qs.customer) qs.customer = 'my_customer';
if (!returnAll) qs.maxResults = 50;
expect(qs).toEqual({
customFieldMask: 'Custom1,Custom2',
fields: 'users(primaryEmail,id)',
customer: 'my_customer',
maxResults: 50,
});
});
});
describe('GSuiteAdmin Node - user:update logic', () => {
it('should build suspended, roles, and customSchemas', async () => {
const mockCall = jest.fn().mockResolvedValue([{ success: true }]);
(googleApiRequest as jest.Mock).mockImplementation(mockCall);
const mockContext = {
getNode: () => ({ name: 'GSuiteAdmin' }),
getNodeParameter: jest.fn((paramName: string) => {
switch (paramName) {
case 'resource':
return 'user';
case 'operation':
return 'update';
case 'userId':
return 'user-id-123';
case 'updateFields':
return {
suspendUi: true,
roles: ['superAdmin', 'groupsReader'],
customFields: {
fieldValues: [
{ schemaName: 'CustomSchema1', fieldName: 'fieldA', value: 'valueA' },
{ schemaName: 'CustomSchema1', fieldName: 'fieldB', value: 'valueB' },
{ schemaName: 'CustomSchema2', fieldName: 'fieldX', value: 'valueX' },
],
},
};
default:
return undefined;
}
}),
helpers: {
returnJsonArray: (data: any) => data,
constructExecutionMetaData: (data: any) => data,
},
continueOnFail: () => false,
getInputData: () => [{ json: {} }],
} as unknown as IExecuteFunctions;
await new GSuiteAdmin().execute.call(mockContext);
const calledBody = mockCall.mock.calls[0][2];
expect(calledBody.suspended).toBe(true);
expect(calledBody.roles).toEqual({
superAdmin: true,
groupsAdmin: false,
groupsReader: true,
groupsEditor: false,
userManagement: false,
helpDeskAdmin: false,
servicesAdmin: false,
inventoryReportingAdmin: false,
storageAdmin: false,
directorySyncAdmin: false,
mobileAdmin: false,
});
expect(calledBody.customSchemas).toEqual({
CustomSchema1: {
fieldA: 'valueA',
fieldB: 'valueB',
},
CustomSchema2: {
fieldX: 'valueX',
},
});
});
it('should throw error for invalid custom fields', async () => {
const mockCall = jest.fn();
(googleApiRequest as jest.Mock).mockImplementation(mockCall);
const mockContextInvalidFields = {
getNode: () => ({ name: 'GSuiteAdmin' }),
getNodeParameter: jest.fn((paramName: string) => {
switch (paramName) {
case 'resource':
return 'user';
case 'operation':
return 'update';
case 'userId':
return 'user-id-456';
case 'updateFields':
return {
customFields: {
fieldValues: [
{ schemaName: '', fieldName: 'valid', value: 'ok' },
{ schemaName: 'ValidSchema', fieldName: 'valid', value: 'ok' },
],
},
};
default:
return undefined;
}
}),
helpers: {
returnJsonArray: (data: any) => data,
constructExecutionMetaData: (data: any) => data,
},
continueOnFail: () => false,
getInputData: () => [{ json: {} }],
} as unknown as IExecuteFunctions;
await expect(new GSuiteAdmin().execute.call(mockContextInvalidFields)).rejects.toThrow(
'Invalid custom field data',
);
expect(mockCall).not.toHaveBeenCalled();
});
it('should throw an error if username is empty', () => {
const mock = { getNode: () => ({}) } as IExecuteFunctions;
expect(() => {
const username = '';
if (!username) {
throw new NodeOperationError(mock.getNode(), "The parameter 'Username' is empty", {
itemIndex: 0,
description: "Please fill in the 'Username' parameter to create the user",
});
}
}).toThrow("The parameter 'Username' is empty");
});
});
describe('GSuiteAdmin Node - Error Handling', () => {
it('should throw a NodeOperationError if the error is an instance of NodeOperationError', async () => {
const mockContext = {
getNode: () => ({ name: 'GSuiteAdmin' }),
continueOnFail: () => false,
helpers: {
constructExecutionMetaData: jest.fn(),
returnJsonArray: jest.fn(),
},
} as unknown as IExecuteFunctions;
const error = new NodeOperationError(mockContext.getNode(), 'Some error message');
await expect(async () => {
throw error;
}).rejects.toThrow(NodeOperationError);
});
it('should handle error when continueOnFail is true and constructExecutionMetaData is called', async () => {
const mockContext = {
getNode: () => ({ name: 'GSuiteAdmin' }),
continueOnFail: () => true,
helpers: {
constructExecutionMetaData: jest.fn().mockReturnValue([{ message: 'mock error data' }]),
returnJsonArray: jest.fn().mockReturnValue([]),
},
} as unknown as IExecuteFunctions;
const error = new Error('Some error message');
await expect(async () => {
if (error instanceof NodeOperationError) {
throw error;
}
if (mockContext.continueOnFail()) {
const executionErrorData = mockContext.helpers.constructExecutionMetaData(
mockContext.helpers.returnJsonArray({
message: 'Operation "update" failed for resource "user".',
description: error.message,
}),
{ itemData: { item: 0 } },
);
if (executionErrorData) {
returnData.push(...executionErrorData);
} else {
console.error('executionErrorData is not iterable:', executionErrorData);
}
}
throw new NodeOperationError(
mockContext.getNode(),
'Operation "update" failed for resource "user".',
{
description: `Please check the input parameters and ensure the API request is correctly formatted. Details: ${error.message}`,
itemIndex: 0,
},
);
}).rejects.toThrow(NodeOperationError);
});
it('should throw a NodeOperationError if an unknown error is thrown and continueOnFail is false', async () => {
const mockContext = {
getNode: () => ({ name: 'GSuiteAdmin' }),
continueOnFail: () => false,
helpers: {
constructExecutionMetaData: jest.fn(),
returnJsonArray: jest.fn(),
},
} as unknown as IExecuteFunctions;
const error = new Error('Some unknown error');
await expect(async () => {
if (error instanceof NodeOperationError) {
throw error;
}
if (!mockContext.continueOnFail()) {
throw new NodeOperationError(
mockContext.getNode(),
'Operation "update" failed for resource "user".',
{
description: `Please check the input parameters and ensure the API request is correctly formatted. Details: ${error.message}`,
itemIndex: 0,
},
);
}
}).rejects.toThrow(NodeOperationError);
});
});