first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
import { sign, type Request } from 'aws4';
|
||||
import type { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
import { Aws } from '../Aws.credentials';
|
||||
import type { AwsIamCredentialsType } from '../common/aws/types';
|
||||
|
||||
jest.mock('aws4', () => ({
|
||||
sign: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Aws Credential', () => {
|
||||
const aws = new Aws();
|
||||
let mockSign: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSign = sign as unknown as jest.Mock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should have correct properties', () => {
|
||||
expect(aws.name).toBe('aws');
|
||||
expect(aws.displayName).toBe('AWS (IAM)');
|
||||
expect(aws.documentationUrl).toBe('aws');
|
||||
expect(aws.icon).toEqual({ light: 'file:icons/AWS.svg', dark: 'file:icons/AWS.dark.svg' });
|
||||
expect(aws.properties.length).toBeGreaterThan(0);
|
||||
expect(aws.test.request.baseURL).toBe(
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
'={{$credentials.region.startsWith("cn-") ? `https://sts.${$credentials.region}.amazonaws.com.cn` : `https://sts.${$credentials.region}.amazonaws.com`}}',
|
||||
);
|
||||
expect(aws.test.request.url).toBe('?Action=GetCallerIdentity&Version=2011-06-15');
|
||||
expect(aws.test.request.method).toBe('POST');
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
const credentials: AwsIamCredentialsType = {
|
||||
region: 'eu-central-1',
|
||||
accessKeyId: 'hakuna',
|
||||
secretAccessKey: 'matata',
|
||||
customEndpoints: false,
|
||||
temporaryCredentials: false,
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
qs: {},
|
||||
body: {},
|
||||
headers: {},
|
||||
baseURL: 'https://sts.eu-central-1.amazonaws.com',
|
||||
url: '?Action=GetCallerIdentity&Version=2011-06-15',
|
||||
method: 'POST',
|
||||
returnFullResponse: true,
|
||||
};
|
||||
|
||||
const signOpts: Request & IHttpRequestOptions = {
|
||||
qs: {},
|
||||
body: undefined,
|
||||
headers: {},
|
||||
baseURL: 'https://sts.eu-central-1.amazonaws.com',
|
||||
url: '?Action=GetCallerIdentity&Version=2011-06-15',
|
||||
method: 'POST',
|
||||
returnFullResponse: true,
|
||||
host: 'sts.eu-central-1.amazonaws.com',
|
||||
path: '/?Action=GetCallerIdentity&Version=2011-06-15',
|
||||
region: 'eu-central-1',
|
||||
};
|
||||
|
||||
const securityHeaders = {
|
||||
accessKeyId: 'hakuna',
|
||||
secretAccessKey: 'matata',
|
||||
sessionToken: undefined,
|
||||
};
|
||||
|
||||
it('should call sign with correct parameters', async () => {
|
||||
const result = await aws.authenticate(credentials, requestOptions);
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(signOpts, securityHeaders);
|
||||
|
||||
expect(result.method).toBe('POST');
|
||||
expect(result.url).toBe(
|
||||
'https://sts.eu-central-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct options with custom endpoint', async () => {
|
||||
const customEndpoint = 'https://custom.endpoint.com';
|
||||
const result = await aws.authenticate(
|
||||
{ ...credentials, customEndpoints: true, snsEndpoint: customEndpoint },
|
||||
{ ...requestOptions, url: '', baseURL: '', qs: { service: 'sns' } },
|
||||
);
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
{
|
||||
...signOpts,
|
||||
baseURL: '',
|
||||
path: '/',
|
||||
url: '',
|
||||
qs: {
|
||||
service: 'sns',
|
||||
},
|
||||
host: 'custom.endpoint.com',
|
||||
},
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.method).toBe('POST');
|
||||
expect(result.url).toBe(`${customEndpoint}/`);
|
||||
});
|
||||
|
||||
it('should return correct options with temporary credentials', async () => {
|
||||
const result = await aws.authenticate(
|
||||
{ ...credentials, temporaryCredentials: true, sessionToken: 'test-token' },
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(signOpts, {
|
||||
...securityHeaders,
|
||||
sessionToken: 'test-token',
|
||||
});
|
||||
expect(result.method).toBe('POST');
|
||||
expect(result.url).toBe(
|
||||
'https://sts.eu-central-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct options for a global AWS service', async () => {
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
url: 'https://iam.amazonaws.com',
|
||||
baseURL: '',
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
{
|
||||
...signOpts,
|
||||
baseURL: '',
|
||||
path: '/',
|
||||
host: 'iam.amazonaws.com',
|
||||
url: 'https://iam.amazonaws.com',
|
||||
},
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.method).toBe('POST');
|
||||
expect(result.url).toBe('https://iam.amazonaws.com/');
|
||||
});
|
||||
|
||||
it('should handle an IRequestOptions object with form instead of body', async () => {
|
||||
const result = await aws.authenticate({ ...credentials }, {
|
||||
...requestOptions,
|
||||
body: undefined,
|
||||
form: {
|
||||
Action: 'ListUsers',
|
||||
Version: '2010-05-08',
|
||||
},
|
||||
baseURL: '',
|
||||
url: 'https://iam.amazonaws.com',
|
||||
host: 'iam.amazonaws.com',
|
||||
path: '/',
|
||||
} as IHttpRequestOptions);
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
{
|
||||
...signOpts,
|
||||
form: {
|
||||
Action: 'ListUsers',
|
||||
Version: '2010-05-08',
|
||||
},
|
||||
body: 'Action=ListUsers&Version=2010-05-08',
|
||||
host: 'iam.amazonaws.com',
|
||||
url: 'https://iam.amazonaws.com',
|
||||
baseURL: '',
|
||||
path: '/',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
// PR #14037 introduces region normalization for global endpoints
|
||||
// This test works with or without the normalization
|
||||
region: expect.stringMatching(/[a-z]{2}-[a-z]+-[0-9]+/),
|
||||
},
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.method).toBe('POST');
|
||||
expect(result.url).toBe('https://iam.amazonaws.com/');
|
||||
});
|
||||
|
||||
describe('China regions', () => {
|
||||
const chinaCredentials: AwsIamCredentialsType = {
|
||||
region: 'cn-north-1',
|
||||
accessKeyId: 'hakuna',
|
||||
secretAccessKey: 'matata',
|
||||
customEndpoints: false,
|
||||
temporaryCredentials: false,
|
||||
};
|
||||
|
||||
it('should use amazonaws.com.cn domain for cn-north-1 region', async () => {
|
||||
const result = await aws.authenticate(chinaCredentials, {
|
||||
...requestOptions,
|
||||
url: '',
|
||||
baseURL: '',
|
||||
qs: { service: 's3' },
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 's3.cn-north-1.amazonaws.com.cn',
|
||||
region: 'cn-north-1',
|
||||
}),
|
||||
{
|
||||
accessKeyId: 'hakuna',
|
||||
secretAccessKey: 'matata',
|
||||
sessionToken: undefined,
|
||||
},
|
||||
);
|
||||
expect(result.url).toBe('https://s3.cn-north-1.amazonaws.com.cn/');
|
||||
});
|
||||
|
||||
it('should handle custom endpoints for China regions', async () => {
|
||||
const customEndpoint = 'https://custom.china.endpoint.com.cn';
|
||||
const result = await aws.authenticate(
|
||||
{ ...chinaCredentials, customEndpoints: true, s3Endpoint: customEndpoint },
|
||||
{ ...requestOptions, url: '', baseURL: '', qs: { service: 's3' } },
|
||||
);
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'custom.china.endpoint.com.cn',
|
||||
}),
|
||||
{
|
||||
accessKeyId: 'hakuna',
|
||||
secretAccessKey: 'matata',
|
||||
sessionToken: undefined,
|
||||
},
|
||||
);
|
||||
expect(result.url).toBe(`${customEndpoint}/`);
|
||||
});
|
||||
|
||||
it('should parse China region URLs correctly', async () => {
|
||||
const result = await aws.authenticate(chinaCredentials, {
|
||||
...requestOptions,
|
||||
url: 'https://s3.cn-north-1.amazonaws.com.cn/bucket/key',
|
||||
baseURL: '',
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 's3.cn-north-1.amazonaws.com.cn',
|
||||
region: 'cn-north-1',
|
||||
path: '/bucket/key',
|
||||
}),
|
||||
{
|
||||
accessKeyId: 'hakuna',
|
||||
secretAccessKey: 'matata',
|
||||
sessionToken: undefined,
|
||||
},
|
||||
);
|
||||
expect(result.url).toBe('https://s3.cn-north-1.amazonaws.com.cn/bucket/key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regular regions (non-China)', () => {
|
||||
it('should use amazonaws.com domain for regular regions', async () => {
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
url: '',
|
||||
baseURL: '',
|
||||
qs: { service: 's3' },
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 's3.eu-central-1.amazonaws.com',
|
||||
region: 'eu-central-1',
|
||||
}),
|
||||
{
|
||||
accessKeyId: 'hakuna',
|
||||
secretAccessKey: 'matata',
|
||||
sessionToken: undefined,
|
||||
},
|
||||
);
|
||||
expect(result.url).toBe('https://s3.eu-central-1.amazonaws.com/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Body handling', () => {
|
||||
it('should stringify object body content', async () => {
|
||||
const objectBody = { key: 'value', nested: { prop: 'test' } };
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
body: objectBody,
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify(objectBody),
|
||||
}),
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.body).toBe(JSON.stringify(objectBody));
|
||||
});
|
||||
|
||||
it('should not stringify object body content when Content-Length header is present', async () => {
|
||||
const objectBody = { key: 'value', nested: { prop: 'test' } };
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
body: objectBody,
|
||||
headers: {
|
||||
'Content-Length': '100',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: objectBody,
|
||||
}),
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.body).toBe(objectBody);
|
||||
});
|
||||
|
||||
it('should not stringify object body content when content-length header is present (lowercase)', async () => {
|
||||
const objectBody = { key: 'value', nested: { prop: 'test' } };
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
body: objectBody,
|
||||
headers: {
|
||||
'content-length': '100',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: objectBody,
|
||||
}),
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.body).toBe(objectBody);
|
||||
});
|
||||
|
||||
it('should not stringify string body content', async () => {
|
||||
const stringBody = 'test string body';
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
body: stringBody,
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: stringBody,
|
||||
}),
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.body).toBe(stringBody);
|
||||
});
|
||||
|
||||
it('should not stringify Buffer body content', async () => {
|
||||
const bufferBody = Buffer.from('test buffer');
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
body: bufferBody,
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: bufferBody,
|
||||
}),
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.body).toBe(bufferBody);
|
||||
});
|
||||
|
||||
it('should handle null body content', async () => {
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
body: null,
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: null,
|
||||
}),
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.body).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle undefined body content', async () => {
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
body: undefined,
|
||||
});
|
||||
|
||||
expect(mockSign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: undefined,
|
||||
}),
|
||||
securityHeaders,
|
||||
);
|
||||
expect(result.body).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import nock from 'nock';
|
||||
|
||||
import { MicrosoftExcelOAuth2Api } from '../MicrosoftExcelOAuth2Api.credentials';
|
||||
|
||||
describe('MicrosoftExcelOAuth2Api Credential', () => {
|
||||
const microsoftExcelOAuth2Api = new MicrosoftExcelOAuth2Api();
|
||||
const defaultScopes = ['openid', 'offline_access', 'Files.ReadWrite'];
|
||||
|
||||
// Shared OAuth2 configuration
|
||||
const baseUrl = 'https://login.microsoftonline.com';
|
||||
const authorizationUri = `${baseUrl}/common/oauth2/v2.0/authorize`;
|
||||
const accessTokenUri = `${baseUrl}/common/oauth2/v2.0/token`;
|
||||
const redirectUri = 'http://localhost:5678/rest/oauth2-credential/callback';
|
||||
const clientId = 'test-client-id';
|
||||
const clientSecret = 'test-client-secret';
|
||||
|
||||
const createOAuthClient = (scopes: string[]) =>
|
||||
new ClientOAuth2({
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessTokenUri,
|
||||
authorizationUri,
|
||||
redirectUri,
|
||||
scopes,
|
||||
});
|
||||
|
||||
const mockTokenEndpoint = (code: string, responseScopes: string[]) => {
|
||||
nock(baseUrl)
|
||||
.post('/common/oauth2/v2.0/token', (body: Record<string, unknown>) => {
|
||||
return (
|
||||
body.code === code &&
|
||||
body.grant_type === 'authorization_code' &&
|
||||
body.redirect_uri === redirectUri
|
||||
);
|
||||
})
|
||||
.reply(200, {
|
||||
access_token: 'test-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: responseScopes.join(' '),
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should have correct credential metadata', () => {
|
||||
expect(microsoftExcelOAuth2Api.name).toBe('microsoftExcelOAuth2Api');
|
||||
expect(microsoftExcelOAuth2Api.extends).toEqual(['microsoftOAuth2Api']);
|
||||
|
||||
// Verify default scopes are correctly defined
|
||||
const enabledScopesProperty = microsoftExcelOAuth2Api.properties.find(
|
||||
(p) => p.name === 'enabledScopes',
|
||||
);
|
||||
expect(enabledScopesProperty?.default).toBe('openid offline_access Files.ReadWrite');
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with default scopes', () => {
|
||||
it('should include default scopes in authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
// Verify the authorization URI contains the correct scopes
|
||||
// Scopes can be encoded with either %20 or + for spaces
|
||||
expect(authUri).toMatch(/scope=(openid[+%20]offline_access[+%20]Files\.ReadWrite)/);
|
||||
expect(authUri).toContain(`client_id=${clientId}`);
|
||||
expect(authUri).toContain('response_type=code');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with default scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, defaultScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toBe('openid offline_access Files.ReadWrite');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with custom scopes', () => {
|
||||
const customScopes = [
|
||||
'openid',
|
||||
'offline_access',
|
||||
'Files.ReadWrite',
|
||||
'Files.Read.All',
|
||||
'Sites.ReadWrite.All',
|
||||
];
|
||||
|
||||
it('should include custom scopes in authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('scope=');
|
||||
expect(authUri).toContain('openid');
|
||||
expect(authUri).toContain('Files.Read.All');
|
||||
expect(authUri).toContain('Sites.ReadWrite.All');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with custom scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, customScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('openid');
|
||||
expect(token.data.scope).toContain('offline_access');
|
||||
expect(token.data.scope).toContain('Files.ReadWrite');
|
||||
expect(token.data.scope).toContain('Files.Read.All');
|
||||
expect(token.data.scope).toContain('Sites.ReadWrite.All');
|
||||
});
|
||||
|
||||
it('should handle completely different custom scopes', async () => {
|
||||
const differentScopes = ['User.Read', 'Mail.Read', 'Calendar.ReadWrite'];
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, differentScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(differentScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
// Verify authorization URI has the different scopes
|
||||
expect(authUri).toContain('User.Read');
|
||||
expect(authUri).toContain('Mail.Read');
|
||||
expect(authUri).toContain('Calendar.ReadWrite');
|
||||
expect(authUri).not.toContain('Files.ReadWrite');
|
||||
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
// Verify token response has the different scopes
|
||||
expect(token.data.scope).toContain('User.Read');
|
||||
expect(token.data.scope).toContain('Mail.Read');
|
||||
expect(token.data.scope).toContain('Calendar.ReadWrite');
|
||||
expect(token.data.scope).not.toContain('Files.ReadWrite');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import nock from 'nock';
|
||||
|
||||
import { MicrosoftTeamsOAuth2Api } from '../MicrosoftTeamsOAuth2Api.credentials';
|
||||
|
||||
describe('MicrosoftTeamsOAuth2Api Credential', () => {
|
||||
const microsoftTeamsOAuth2Api = new MicrosoftTeamsOAuth2Api();
|
||||
const defaultScopes = [
|
||||
'openid',
|
||||
'offline_access',
|
||||
'User.Read.All',
|
||||
'Group.ReadWrite.All',
|
||||
'Chat.ReadWrite',
|
||||
'ChannelMessage.Read.All',
|
||||
];
|
||||
|
||||
// Shared OAuth2 configuration
|
||||
const baseUrl = 'https://login.microsoftonline.com';
|
||||
const authorizationUri = `${baseUrl}/common/oauth2/v2.0/authorize`;
|
||||
const accessTokenUri = `${baseUrl}/common/oauth2/v2.0/token`;
|
||||
const redirectUri = 'http://localhost:5678/rest/oauth2-credential/callback';
|
||||
const clientId = 'test-client-id';
|
||||
const clientSecret = 'test-client-secret';
|
||||
|
||||
const createOAuthClient = (scopes: string[]) =>
|
||||
new ClientOAuth2({
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessTokenUri,
|
||||
authorizationUri,
|
||||
redirectUri,
|
||||
scopes,
|
||||
});
|
||||
|
||||
const mockTokenEndpoint = (code: string, responseScopes: string[]) => {
|
||||
nock(baseUrl)
|
||||
.post('/common/oauth2/v2.0/token', (body: Record<string, unknown>) => {
|
||||
return (
|
||||
body.code === code &&
|
||||
body.grant_type === 'authorization_code' &&
|
||||
body.redirect_uri === redirectUri
|
||||
);
|
||||
})
|
||||
.reply(200, {
|
||||
access_token: 'test-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: responseScopes.join(' '),
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should have correct credential metadata', () => {
|
||||
expect(microsoftTeamsOAuth2Api.name).toBe('microsoftTeamsOAuth2Api');
|
||||
expect(microsoftTeamsOAuth2Api.extends).toEqual(['microsoftOAuth2Api']);
|
||||
|
||||
// Verify default scopes are correctly defined
|
||||
const enabledScopesProperty = microsoftTeamsOAuth2Api.properties.find(
|
||||
(p) => p.name === 'enabledScopes',
|
||||
);
|
||||
expect(enabledScopesProperty?.default).toBe(
|
||||
'openid offline_access User.Read.All Group.ReadWrite.All Chat.ReadWrite ChannelMessage.Read.All',
|
||||
);
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with default scopes', () => {
|
||||
it('should include default scopes in authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
// Verify the authorization URI contains the correct scopes
|
||||
expect(authUri).toContain('scope=');
|
||||
expect(authUri).toContain('openid');
|
||||
expect(authUri).toContain('offline_access');
|
||||
expect(authUri).toContain('User.Read.All');
|
||||
expect(authUri).toContain('Group.ReadWrite.All');
|
||||
expect(authUri).toContain('Chat.ReadWrite');
|
||||
expect(authUri).toContain('ChannelMessage.Read.All');
|
||||
expect(authUri).toContain(`client_id=${clientId}`);
|
||||
expect(authUri).toContain('response_type=code');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with default scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, defaultScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('openid');
|
||||
expect(token.data.scope).toContain('offline_access');
|
||||
expect(token.data.scope).toContain('User.Read.All');
|
||||
expect(token.data.scope).toContain('Group.ReadWrite.All');
|
||||
expect(token.data.scope).toContain('Chat.ReadWrite');
|
||||
expect(token.data.scope).toContain('ChannelMessage.Read.All');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with custom scopes', () => {
|
||||
const customScopes = [
|
||||
'openid',
|
||||
'offline_access',
|
||||
'User.Read.All',
|
||||
'Group.ReadWrite.All',
|
||||
'Chat.ReadWrite',
|
||||
'ChannelMessage.Read.All',
|
||||
'Chat.Read.All',
|
||||
'Team.ReadBasic.All',
|
||||
'Subscription.ReadWrite.All',
|
||||
];
|
||||
|
||||
it('should include custom scopes in authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('scope=');
|
||||
expect(authUri).toContain('openid');
|
||||
expect(authUri).toContain('Chat.Read.All');
|
||||
expect(authUri).toContain('Team.ReadBasic.All');
|
||||
expect(authUri).toContain('Subscription.ReadWrite.All');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with custom scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, customScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('openid');
|
||||
expect(token.data.scope).toContain('offline_access');
|
||||
expect(token.data.scope).toContain('User.Read.All');
|
||||
expect(token.data.scope).toContain('Chat.Read.All');
|
||||
expect(token.data.scope).toContain('Team.ReadBasic.All');
|
||||
expect(token.data.scope).toContain('Subscription.ReadWrite.All');
|
||||
});
|
||||
|
||||
it('should handle completely different custom scopes', async () => {
|
||||
const differentScopes = ['openid', 'offline_access', 'Calendars.Read', 'Mail.Send'];
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, differentScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(differentScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
// Verify authorization URI has the different scopes
|
||||
expect(authUri).toContain('Calendars.Read');
|
||||
expect(authUri).toContain('Mail.Send');
|
||||
expect(authUri).not.toContain('Chat.ReadWrite');
|
||||
expect(authUri).not.toContain('ChannelMessage.Read.All');
|
||||
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
// Verify token response has the different scopes
|
||||
expect(token.data.scope).toContain('Calendars.Read');
|
||||
expect(token.data.scope).toContain('Mail.Send');
|
||||
expect(token.data.scope).not.toContain('Chat.ReadWrite');
|
||||
expect(token.data.scope).not.toContain('ChannelMessage.Read.All');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ICredentialDataDecryptedObject, IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
import { OpenAiApi } from '../OpenAiApi.credentials';
|
||||
|
||||
describe('OpenAiApi Credential', () => {
|
||||
const openAiApi = new OpenAiApi();
|
||||
|
||||
it('should have correct properties', () => {
|
||||
expect(openAiApi.name).toBe('openAiApi');
|
||||
expect(openAiApi.displayName).toBe('OpenAi');
|
||||
expect(openAiApi.documentationUrl).toBe('openai');
|
||||
expect(openAiApi.properties).toHaveLength(6);
|
||||
expect(openAiApi.test.request.baseURL).toBe('={{$credentials?.url}}');
|
||||
expect(openAiApi.test.request.url).toBe('/models');
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
it('should add Authorization header with API key only', async () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
apiKey: 'sk-test123456789',
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
headers: {},
|
||||
url: '/models',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
const result = await openAiApi.authenticate(credentials, requestOptions);
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
Authorization: 'Bearer sk-test123456789',
|
||||
'OpenAI-Organization': undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add Authorization and Organization headers', async () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
apiKey: 'sk-test123456789',
|
||||
organizationId: 'org-123',
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
headers: {},
|
||||
url: '/models',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
const result = await openAiApi.authenticate(credentials, requestOptions);
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
Authorization: 'Bearer sk-test123456789',
|
||||
'OpenAI-Organization': 'org-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should add custom header when header toggle is enabled', async () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
apiKey: 'sk-test123456789',
|
||||
organizationId: 'org-123',
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value-123',
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
headers: {},
|
||||
url: '/models',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
const result = await openAiApi.authenticate(credentials, requestOptions);
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
Authorization: 'Bearer sk-test123456789',
|
||||
'OpenAI-Organization': 'org-123',
|
||||
'X-Custom-Header': 'custom-value-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add custom header when header toggle is disabled', async () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
apiKey: 'sk-test123456789',
|
||||
header: false,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value-123',
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
headers: {},
|
||||
url: '/models',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
const result = await openAiApi.authenticate(credentials, requestOptions);
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
Authorization: 'Bearer sk-test123456789',
|
||||
'OpenAI-Organization': undefined,
|
||||
});
|
||||
expect(result.headers?.['X-Custom-Header']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve existing headers', async () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
apiKey: 'sk-test123456789',
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value-123',
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
url: '/models',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
const result = await openAiApi.authenticate(credentials, requestOptions);
|
||||
|
||||
const raw =
|
||||
typeof (result.headers as any)?.get === 'function'
|
||||
? Object.fromEntries((result.headers as unknown as Headers).entries())
|
||||
: (result.headers as Record<string, string | undefined>);
|
||||
|
||||
const headers = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k.toLowerCase(), v]));
|
||||
|
||||
expect(headers).toEqual(
|
||||
expect.objectContaining({
|
||||
authorization: 'Bearer sk-test123456789',
|
||||
'x-custom-header': 'custom-value-123',
|
||||
'openai-organization': undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty organization ID', async () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
apiKey: 'sk-test123456789',
|
||||
organizationId: '',
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
headers: {},
|
||||
url: '/models',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
const result = await openAiApi.authenticate(credentials, requestOptions);
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
Authorization: 'Bearer sk-test123456789',
|
||||
'OpenAI-Organization': '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing headers when adding auth headers', async () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
apiKey: 'sk-test123456789',
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
headers: {
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
url: '/assistants',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
const result = await openAiApi.authenticate(credentials, requestOptions);
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
Authorization: 'Bearer sk-test123456789',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing headers even with custom header option enabled', async () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
apiKey: 'sk-test123456789',
|
||||
header: true,
|
||||
headerName: 'X-Additional-Header',
|
||||
headerValue: 'additional-value',
|
||||
};
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
headers: {
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
'X-Existing-Header': 'existing-value',
|
||||
},
|
||||
url: '/assistants/asst_123',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
const result = await openAiApi.authenticate(credentials, requestOptions);
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
'X-Existing-Header': 'existing-value',
|
||||
Authorization: 'Bearer sk-test123456789',
|
||||
'X-Additional-Header': 'additional-value',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getUrl } from '@credentials/common/http';
|
||||
|
||||
describe('getUrl', () => {
|
||||
it('should throw an error if no url is provided', () => {
|
||||
expect(() => getUrl({})).toThrow('No URL found in request options');
|
||||
});
|
||||
it('should return the url', () => {
|
||||
const url = getUrl({ url: 'https://example.com' });
|
||||
expect(url).toBe('https://example.com/');
|
||||
});
|
||||
it('should return the url with baseURL', () => {
|
||||
const url = getUrl({ baseURL: 'https://example.com', url: '/test' });
|
||||
expect(url).toBe('https://example.com/test');
|
||||
});
|
||||
it('should return the url with uri', () => {
|
||||
const url = getUrl({ uri: 'https://example.com/test' });
|
||||
expect(url).toBe('https://example.com/test');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user