diff --git a/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.test.ts new file mode 100644 index 0000000000000..07252821d582a --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.test.ts @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { OAuthAuthorizationService } from './oauth_authorization_service'; +import { actionsClientMock } from '../actions_client/actions_client.mock'; +import { createMockConnector } from '../application/connector/mocks'; + +const mockActionsClient = actionsClientMock.create(); + +const mockEncryptedSavedObjectsClient = { + getDecryptedAsInternalUser: jest.fn(), +}; + +const createService = () => + new OAuthAuthorizationService({ + actionsClient: mockActionsClient as never, + encryptedSavedObjectsClient: mockEncryptedSavedObjectsClient as never, + }); + +describe('OAuthAuthorizationService', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('getOAuthConfig', () => { + it('returns OAuth config from decrypted secrets', async () => { + const service = createService(); + const getResult = createMockConnector({ + id: 'connector-1', + config: { authType: 'oauth_authorization_code' }, + }); + mockActionsClient.get.mockResolvedValue(getResult); + mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + attributes: { + secrets: { + authorizationUrl: 'https://provider.example.com/authorize', + clientId: 'secret-client-id', + scope: 'openid email', + }, + config: {}, + }, + }); + + const result = await service.getOAuthConfig('connector-1', undefined); + + expect(result).toEqual({ + authorizationUrl: 'https://provider.example.com/authorize', + clientId: 'secret-client-id', + scope: 'openid email', + }); + expect(mockActionsClient.get).toHaveBeenCalledWith({ id: 'connector-1' }); + expect(mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + 'action', + 'connector-1', + { namespace: undefined } + ); + }); + + it('falls back to config when secrets are missing fields', async () => { + const service = createService(); + const getResult = createMockConnector({ + id: 'connector-1', + config: { + authType: 'oauth_authorization_code', + authorizationUrl: 'https://config-provider.example.com/authorize', + clientId: 'config-client-id', + scope: 'profile', + }, + }); + mockActionsClient.get.mockResolvedValue(getResult); + mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + attributes: { + secrets: {}, + config: { + authorizationUrl: 'https://config-provider.example.com/authorize', + clientId: 'config-client-id', + scope: 'profile', + }, + }, + }); + + const result = await service.getOAuthConfig('connector-1', undefined); + + expect(result).toEqual({ + authorizationUrl: 'https://config-provider.example.com/authorize', + clientId: 'config-client-id', + scope: 'profile', + }); + }); + + it('supports auth.type for OAuth validation', async () => { + const service = createService(); + const getResult = createMockConnector({ + id: 'connector-1', + config: { auth: { type: 'oauth_authorization_code' } }, + }); + mockActionsClient.get.mockResolvedValue(getResult); + mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + attributes: { + secrets: { + authorizationUrl: 'https://provider.example.com/authorize', + clientId: 'client-id', + }, + config: {}, + }, + }); + + const result = await service.getOAuthConfig('connector-1', undefined); + + expect(result).toEqual({ + authorizationUrl: 'https://provider.example.com/authorize', + clientId: 'client-id', + scope: undefined, + }); + }); + + it('passes namespace when provided', async () => { + const service = createService(); + const getResult = createMockConnector({ + id: 'connector-1', + config: { authType: 'oauth_authorization_code' }, + }); + mockActionsClient.get.mockResolvedValue(getResult); + mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + attributes: { + secrets: { + authorizationUrl: 'https://provider.example.com/authorize', + clientId: 'client-id', + }, + config: {}, + }, + }); + + await service.getOAuthConfig('connector-1', 'custom-namespace'); + + expect(mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + 'action', + 'connector-1', + { namespace: 'custom-namespace' } + ); + }); + + it('throws when connector does not use OAuth Authorization Code flow', async () => { + const service = createService(); + const getResult = createMockConnector({ + id: 'connector-1', + config: { authType: 'basic' }, + }); + mockActionsClient.get.mockResolvedValue(getResult); + + await expect(service.getOAuthConfig('connector-1', undefined)).rejects.toThrow( + 'Connector does not use OAuth Authorization Code flow' + ); + }); + + it.each([ + ['authorizationUrl', { clientId: 'client-id' }], + ['clientId', { authorizationUrl: 'https://provider.example.com/authorize' }], + ])('throws when missing required OAuth config (%s)', async (_, secrets) => { + const service = createService(); + const getResult = createMockConnector({ + id: 'connector-1', + config: { authType: 'oauth_authorization_code' }, + }); + mockActionsClient.get.mockResolvedValue(getResult); + mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + attributes: { + secrets, + config: {}, + }, + }); + + await expect(service.getOAuthConfig('connector-1', undefined)).rejects.toThrow( + 'Connector missing required OAuth configuration (authorizationUrl, clientId)' + ); + }); + }); + + describe('getRedirectUri', () => { + it('returns the correct redirect URI', () => { + expect(OAuthAuthorizationService.getRedirectUri('https://kibana.example.com')).toBe( + 'https://kibana.example.com/api/actions/connector/_oauth_callback' + ); + }); + + it.each([[''], [undefined]])('throws when publicBaseUrl is %j', (publicBaseUrl) => { + expect(() => OAuthAuthorizationService.getRedirectUri(publicBaseUrl)).toThrow( + 'Kibana public URL not configured. Please set server.publicBaseUrl in kibana.yml' + ); + }); + }); + + describe('buildAuthorizationUrl', () => { + it('builds URL with all required parameters', () => { + const service = createService(); + + const url = service.buildAuthorizationUrl({ + baseAuthorizationUrl: 'https://provider.example.com/authorize', + clientId: 'my-client-id', + scope: 'openid email profile', + redirectUri: 'https://kibana.example.com/api/actions/connector/_oauth_callback', + state: 'random-state-value', + codeChallenge: 'code-challenge-value', + }); + + const parsed = new URL(url); + expect(parsed.origin).toBe('https://provider.example.com'); + expect(parsed.pathname).toBe('/authorize'); + expect(parsed.searchParams.get('client_id')).toBe('my-client-id'); + expect(parsed.searchParams.get('response_type')).toBe('code'); + expect(parsed.searchParams.get('redirect_uri')).toBe( + 'https://kibana.example.com/api/actions/connector/_oauth_callback' + ); + expect(parsed.searchParams.get('state')).toBe('random-state-value'); + expect(parsed.searchParams.get('code_challenge')).toBe('code-challenge-value'); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + expect(parsed.searchParams.get('scope')).toBe('openid email profile'); + }); + + it('excludes scope when not provided', () => { + const service = createService(); + + const url = service.buildAuthorizationUrl({ + baseAuthorizationUrl: 'https://provider.example.com/authorize', + clientId: 'my-client-id', + redirectUri: 'https://kibana.example.com/callback', + state: 'state-value', + codeChallenge: 'challenge-value', + }); + + const parsed = new URL(url); + expect(parsed.searchParams.has('scope')).toBe(false); + expect(parsed.searchParams.get('client_id')).toBe('my-client-id'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_client.test.ts new file mode 100644 index 0000000000000..10cb3b67e37a1 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_client.test.ts @@ -0,0 +1,342 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsUtils } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { OAuthStateClient } from './oauth_state_client'; +import { OAUTH_STATE_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; + +jest.mock('@kbn/core/server', () => { + const actual = jest.requireActual('@kbn/core/server'); + return { + ...actual, + SavedObjectsUtils: { + ...actual.SavedObjectsUtils, + generateId: jest.fn().mockReturnValue('generated-id'), + }, + }; +}); + +const mockLogger = loggingSystemMock.create().get(); + +const mockUnsecuredSavedObjectsClient = { + create: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + createPointInTimeFinder: jest.fn(), + bulkDelete: jest.fn(), +}; + +const mockEncryptedSavedObjectsClient = { + getDecryptedAsInternalUser: jest.fn(), +}; + +const createClient = () => + new OAuthStateClient({ + encryptedSavedObjectsClient: mockEncryptedSavedObjectsClient as never, + unsecuredSavedObjectsClient: mockUnsecuredSavedObjectsClient as never, + logger: mockLogger, + }); + +describe('OAuthStateClient', () => { + beforeEach(() => { + jest.resetAllMocks(); + (SavedObjectsUtils.generateId as jest.Mock).mockReturnValue('generated-id'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('create', () => { + it('creates OAuth state with PKCE parameters', async () => { + const client = createClient(); + const now = new Date('2025-06-01T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(now); + mockUnsecuredSavedObjectsClient.create.mockResolvedValue({ + id: 'generated-id', + attributes: { + state: 'mock-state', + codeVerifier: 'mock-verifier', + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + createdAt: '2025-01-01T00:00:00.000Z', + expiresAt: '2025-01-01T00:10:00.000Z', + }, + }); + + const result = await client.create({ + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + }); + + expect(result.state).toEqual( + expect.objectContaining({ + id: 'generated-id', + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + }) + ); + expect(result.codeChallenge).toEqual(expect.any(String)); + expect(result.codeChallenge.length).toBeGreaterThan(0); + + expect(mockUnsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + OAUTH_STATE_SAVED_OBJECT_TYPE, + expect.objectContaining({ + state: expect.any(String), + codeVerifier: expect.any(String), + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + createdAt: now.toISOString(), + expiresAt: '2025-06-01T12:10:00.000Z', // now + 10 minutes + }), + { id: 'generated-id' } + ); + }); + + it('includes createdBy when provided', async () => { + const client = createClient(); + mockUnsecuredSavedObjectsClient.create.mockResolvedValue({ + id: 'generated-id', + attributes: {}, + }); + + await client.create({ + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + createdBy: 'testuser', + }); + + const createdAttributes = mockUnsecuredSavedObjectsClient.create.mock.calls[0][1]; + expect(createdAttributes.createdBy).toBe('testuser'); + }); + + it('throws and logs error on creation failure', async () => { + const client = createClient(); + mockUnsecuredSavedObjectsClient.create.mockRejectedValue(new Error('SO create failed')); + + await expect( + client.create({ + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + }) + ).rejects.toThrow('SO create failed'); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to create OAuth state for connectorId "connector-1"') + ); + }); + }); + + describe('get', () => { + it('returns decrypted OAuth state when found and not expired', async () => { + const client = createClient(); + const futureDate = new Date(Date.now() + 60000).toISOString(); + + mockUnsecuredSavedObjectsClient.find.mockResolvedValue({ + saved_objects: [ + { + id: 'state-id-1', + attributes: { + state: 'test-state', + expiresAt: futureDate, + }, + }, + ], + }); + mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + attributes: { + state: 'test-state', + codeVerifier: 'decrypted-verifier', + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + createdAt: '2025-01-01T00:00:00.000Z', + expiresAt: futureDate, + }, + }); + + const result = await client.get('test-state'); + + expect(result).toEqual({ + id: 'state-id-1', + state: 'test-state', + codeVerifier: 'decrypted-verifier', + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + createdAt: '2025-01-01T00:00:00.000Z', + expiresAt: futureDate, + }); + + expect(mockUnsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + type: OAUTH_STATE_SAVED_OBJECT_TYPE, + filter: `${OAUTH_STATE_SAVED_OBJECT_TYPE}.attributes.state: "test-state"`, + perPage: 1, + }); + expect(mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + OAUTH_STATE_SAVED_OBJECT_TYPE, + 'state-id-1' + ); + }); + + it('returns null when state is not found', async () => { + const client = createClient(); + mockUnsecuredSavedObjectsClient.find.mockResolvedValue({ + saved_objects: [], + }); + + const result = await client.get('nonexistent-state'); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('OAuth state not found for state parameter: nonexistent-state') + ); + }); + + it('returns null and deletes when state is expired', async () => { + const client = createClient(); + const pastDate = new Date(Date.now() - 60000).toISOString(); + + mockUnsecuredSavedObjectsClient.find.mockResolvedValue({ + saved_objects: [ + { + id: 'expired-state-id', + attributes: { + state: 'expired-state', + expiresAt: pastDate, + }, + }, + ], + }); + + const result = await client.get('expired-state'); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('OAuth state expired for state parameter: expired-state') + ); + expect(mockUnsecuredSavedObjectsClient.delete).toHaveBeenCalledWith( + OAUTH_STATE_SAVED_OBJECT_TYPE, + 'expired-state-id' + ); + }); + + it('returns null and logs error on failure', async () => { + const client = createClient(); + mockUnsecuredSavedObjectsClient.find.mockRejectedValue(new Error('find failed')); + + const result = await client.get('some-state'); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch OAuth state for state parameter "some-state"') + ); + }); + }); + + describe('delete', () => { + it('deletes the OAuth state saved object', async () => { + const client = createClient(); + mockUnsecuredSavedObjectsClient.delete.mockResolvedValue({}); + + await client.delete('state-id-1'); + + expect(mockUnsecuredSavedObjectsClient.delete).toHaveBeenCalledWith( + OAUTH_STATE_SAVED_OBJECT_TYPE, + 'state-id-1' + ); + }); + + it('throws and logs error on deletion failure', async () => { + const client = createClient(); + mockUnsecuredSavedObjectsClient.delete.mockRejectedValue(new Error('delete failed')); + + await expect(client.delete('state-id-1')).rejects.toThrow('delete failed'); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to delete OAuth state "state-id-1"') + ); + }); + }); + + describe('cleanupExpiredStates', () => { + it('deletes expired states and returns count', async () => { + const client = createClient(); + + const mockFinder = { + find: jest.fn().mockImplementation(async function* () { + yield { + saved_objects: [ + { id: 'expired-1', type: OAUTH_STATE_SAVED_OBJECT_TYPE }, + { id: 'expired-2', type: OAUTH_STATE_SAVED_OBJECT_TYPE }, + ], + }; + }), + close: jest.fn(), + }; + mockUnsecuredSavedObjectsClient.createPointInTimeFinder.mockReturnValue(mockFinder); + mockUnsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({}); + + const result = await client.cleanupExpiredStates(); + + expect(result).toBe(2); + expect(mockUnsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledWith([ + { type: OAUTH_STATE_SAVED_OBJECT_TYPE, id: 'expired-1' }, + { type: OAUTH_STATE_SAVED_OBJECT_TYPE, id: 'expired-2' }, + ]); + expect(mockFinder.close).toHaveBeenCalled(); + }); + + it('returns 0 and logs error on failure', async () => { + const client = createClient(); + mockUnsecuredSavedObjectsClient.createPointInTimeFinder.mockImplementation(() => { + throw new Error('finder failed'); + }); + + const result = await client.cleanupExpiredStates(); + + expect(result).toBe(0); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to cleanup expired OAuth states') + ); + }); + + it('handles multiple pages of expired states', async () => { + const client = createClient(); + + const mockFinder = { + find: jest.fn().mockImplementation(async function* () { + yield { + saved_objects: [{ id: 'expired-1', type: OAUTH_STATE_SAVED_OBJECT_TYPE }], + }; + yield { + saved_objects: [ + { id: 'expired-2', type: OAUTH_STATE_SAVED_OBJECT_TYPE }, + { id: 'expired-3', type: OAUTH_STATE_SAVED_OBJECT_TYPE }, + ], + }; + }), + close: jest.fn(), + }; + mockUnsecuredSavedObjectsClient.createPointInTimeFinder.mockReturnValue(mockFinder); + mockUnsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({}); + + const result = await client.cleanupExpiredStates(); + + expect(result).toBe(3); + expect(mockUnsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.test.ts index ad887eca7f336..83953745cfdf2 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.test.ts @@ -27,6 +27,7 @@ interface TestOAuthRequestParams { describe('requestOAuthToken', () => { beforeEach(() => { + jest.resetAllMocks(); createAxiosInstanceMock.mockReturnValue(axiosInstanceMock); }); @@ -141,4 +142,119 @@ describe('requestOAuthToken', () => { ] `); }); + + test('sends Basic Auth header and removes credentials from body when useBasicAuth is true', async () => { + const configurationUtilities = actionsConfigMock.create(); + const clientId = 'my-client'; + const clientSecret = 'my-secret'; + axiosInstanceMock.mockReturnValueOnce({ + status: 200, + data: { + token_type: 'Bearer', + access_token: 'token123', + expires_in: 3600, + }, + }); + + await requestOAuthToken( + 'https://test', + 'authorization_code', + configurationUtilities, + mockLogger, + { + client_id: clientId, + client_secret: clientSecret, + some_additional_param: 'value', + }, + true + ); + + const requestConfig = axiosInstanceMock.mock.calls[0][1]; + + // Body should not contain client_id or client_secret + expect(requestConfig.data).not.toContain('client_id'); + expect(requestConfig.data).not.toContain('client_secret'); + expect(requestConfig.data).toContain('grant_type=authorization_code'); + expect(requestConfig.data).toContain('some_additional_param=value'); + + // Should have Basic Auth header + const encoded = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + const expectedHeader = `Basic ${encoded}`; + expect(requestConfig.headers).toEqual( + expect.objectContaining({ + Authorization: expectedHeader, + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }) + ); + }); + + test('includes credentials in body and no Basic Auth header when useBasicAuth is false', async () => { + const configurationUtilities = actionsConfigMock.create(); + const clientId = 'my-client'; + const clientSecret = 'my-secret'; + axiosInstanceMock.mockReturnValueOnce({ + status: 200, + data: { + token_type: 'Bearer', + access_token: 'token123', + expires_in: 3600, + }, + }); + + await requestOAuthToken( + 'https://test', + 'authorization_code', + configurationUtilities, + mockLogger, + { + client_id: clientId, + client_secret: clientSecret, + some_additional_param: 'value', + }, + false + ); + + const requestConfig = axiosInstanceMock.mock.calls[0][1]; + + // Body should contain client_id and client_secret + expect(requestConfig.data).toContain(`client_id=${clientId}`); + expect(requestConfig.data).toContain(`client_secret=${clientSecret}`); + expect(requestConfig.data).toContain('grant_type=authorization_code'); + + // Should NOT have Basic Auth header + expect(requestConfig.headers).not.toHaveProperty('Authorization'); + }); + + test('returns parsed token response on success', async () => { + const configurationUtilities = actionsConfigMock.create(); + axiosInstanceMock.mockReturnValueOnce({ + status: 200, + data: { + token_type: 'Bearer', + access_token: 'access-token-123', + expires_in: 3600, + refresh_token: 'refresh-token-456', + refresh_token_expires_in: 86400, + }, + }); + + const result = await requestOAuthToken( + 'https://test', + 'authorization_code', + configurationUtilities, + mockLogger, + { + client_id: 'my-client', + client_secret: 'my-secret', + } + ); + + expect(result).toEqual({ + tokenType: 'Bearer', + accessToken: 'access-token-123', + expiresIn: 3600, + refreshToken: 'refresh-token-456', + refreshTokenExpiresIn: 86400, + }); + }); }); diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts new file mode 100644 index 0000000000000..8842aa92f2a3f --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts @@ -0,0 +1,369 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('./verify_access_and_context', () => ({ + verifyAccessAndContext: jest.fn(), +})); +jest.mock('../lib/oauth_state_client'); +jest.mock('../lib/oauth_authorization_service'); + +import { httpServiceMock, httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { verifyAccessAndContext } from './verify_access_and_context'; +import { oauthAuthorizeRoute } from './oauth_authorize'; +import { OAuthStateClient } from '../lib/oauth_state_client'; +import { OAuthAuthorizationService } from '../lib/oauth_authorization_service'; + +const MockOAuthStateClient = OAuthStateClient as jest.MockedClass; +const MockOAuthAuthorizationService = OAuthAuthorizationService as jest.MockedClass< + typeof OAuthAuthorizationService +>; + +const mockLogger = loggingSystemMock.create().get(); + +const mockOAuthStateClientInstance = { + create: jest.fn(), + get: jest.fn(), + delete: jest.fn(), + cleanupExpiredStates: jest.fn(), +}; + +const mockOAuthServiceInstance = { + getOAuthConfig: jest.fn(), + buildAuthorizationUrl: jest.fn(), +}; + +const mockEncryptedSavedObjectsClient = { + getClient: jest.fn().mockReturnValue({}), +}; + +const mockSpacesService = { + getSpaceId: jest.fn().mockReturnValue('default'), + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), +}; + +const mockRateLimiter = { + log: jest.fn(), + isRateLimited: jest.fn().mockReturnValue(false), + getLogs: jest.fn(), +}; + +const KIBANA_URL = 'https://kibana.example.com'; + +const createMockCoreSetup = (publicBaseUrl: string | undefined = KIBANA_URL) => ({ + getStartServices: jest.fn().mockResolvedValue([ + { + http: { + basePath: { + publicBaseUrl, + }, + }, + }, + { + encryptedSavedObjects: mockEncryptedSavedObjectsClient, + spaces: { spacesService: mockSpacesService }, + }, + ]), +}); + +const createMockContext = ( + currentUser: { username: string } | null = { username: 'testuser' } +) => ({ + core: Promise.resolve({ + security: { + authc: { + getCurrentUser: jest.fn().mockReturnValue(currentUser), + }, + }, + savedObjects: { + getClient: jest.fn().mockReturnValue({}), + }, + }), + actions: Promise.resolve({ + getActionsClient: jest.fn(), + }), +}); + +describe('oauthAuthorizeRoute', () => { + let router: ReturnType; + + beforeEach(() => { + jest.resetAllMocks(); + router = httpServiceMock.createRouter(); + (verifyAccessAndContext as jest.Mock).mockImplementation((_license, handler) => handler); + + // Restore mock implementations cleared by resetAllMocks + (mockLogger.get as jest.Mock).mockReturnValue(mockLogger); + mockRateLimiter.isRateLimited.mockReturnValue(false); + mockSpacesService.getSpaceId.mockReturnValue('default'); + mockSpacesService.spaceIdToNamespace.mockReturnValue(undefined); + mockEncryptedSavedObjectsClient.getClient.mockReturnValue({}); + + MockOAuthStateClient.mockImplementation(() => mockOAuthStateClientInstance as never); + MockOAuthAuthorizationService.mockImplementation(() => mockOAuthServiceInstance as never); + (OAuthAuthorizationService.getRedirectUri as jest.Mock).mockReturnValue( + 'https://kibana.example.com/api/actions/connector/_oauth_callback' + ); + }); + + const registerRoute = (coreSetup = createMockCoreSetup()) => { + const licenseState = licenseStateMock.create(); + oauthAuthorizeRoute( + router, + licenseState, + mockLogger, + coreSetup as never, + mockRateLimiter as never + ); + return router.post.mock.calls[0]; + }; + + it('registers a POST route at the correct path', () => { + registerRoute(); + + const [config] = router.post.mock.calls[0]; + expect(config.path).toBe('/internal/actions/connector/{connectorId}/_start_oauth_flow'); + }); + + it('throws when no current user', async () => { + const [, handler] = registerRoute(); + const context = createMockContext(null); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + body: {}, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.customError).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 500, + body: { + message: 'User should be authenticated to initiate OAuth authorization.', + }, + }) + ); + }); + + it('returns 429 when rate limited', async () => { + mockRateLimiter.isRateLimited.mockReturnValue(true); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + body: {}, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(mockRateLimiter.log).toHaveBeenCalledWith('testuser', 'authorize'); + expect(res.customError).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 429, + body: { + message: 'Too many authorization attempts. Please try again later.', + }, + }) + ); + }); + + it('returns bad request when public base URL is not configured', async () => { + // Pass null and cast - passing undefined would trigger the default parameter value + const coreSetup = createMockCoreSetup(null as unknown as undefined); + + const [, handler] = registerRoute(coreSetup); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + body: {}, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.badRequest).toHaveBeenCalledWith({ + body: { + message: 'Kibana public URL not configured. Please set server.publicBaseUrl in kibana.yml', + }, + }); + }); + + it('returns bad request for cross-origin returnUrl', async () => { + mockOAuthServiceInstance.getOAuthConfig.mockResolvedValue({ + authorizationUrl: 'https://provider.example.com/authorize', + clientId: 'client-id', + }); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + body: { returnUrl: 'https://evil.example.com/phish' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.badRequest).toHaveBeenCalledWith({ + body: { + message: expect.stringContaining('returnUrl must be same origin as Kibana'), + }, + }); + }); + + it('returns authorization URL on success', async () => { + mockOAuthServiceInstance.getOAuthConfig.mockResolvedValue({ + authorizationUrl: 'https://provider.example.com/authorize', + clientId: 'client-id', + scope: 'openid', + }); + + mockOAuthServiceInstance.buildAuthorizationUrl.mockReturnValue( + 'https://provider.example.com/authorize?client_id=client-id&response_type=code&state=random-state' + ); + mockOAuthStateClientInstance.create.mockResolvedValue({ + state: { + id: 'state-id', + state: 'random-state', + connectorId: 'connector-1', + }, + codeChallenge: 'code-challenge-value', + }); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + body: { returnUrl: 'https://kibana.example.com/app/my-page' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.ok).toHaveBeenCalledWith({ + body: { + authorizationUrl: + 'https://provider.example.com/authorize?client_id=client-id&response_type=code&state=random-state', + state: 'random-state', + }, + }); + + // Verify OAuth state was created with the correct params + expect(mockOAuthStateClientInstance.create).toHaveBeenCalledWith({ + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/my-page', + spaceId: 'default', + }); + + // Verify authorization URL was built with correct params + expect(mockOAuthServiceInstance.buildAuthorizationUrl).toHaveBeenCalledWith({ + baseAuthorizationUrl: 'https://provider.example.com/authorize', + clientId: 'client-id', + scope: 'openid', + redirectUri: 'https://kibana.example.com/api/actions/connector/_oauth_callback', + state: 'random-state', + codeChallenge: 'code-challenge-value', + }); + }); + + it('uses default return URL when not provided', async () => { + mockOAuthServiceInstance.getOAuthConfig.mockResolvedValue({ + authorizationUrl: 'https://provider.example.com/authorize', + clientId: 'client-id', + }); + + mockOAuthServiceInstance.buildAuthorizationUrl.mockReturnValue( + 'https://provider.example.com/authorize?state=s' + ); + mockOAuthStateClientInstance.create.mockResolvedValue({ + state: { id: 'state-id', state: 'random-state' }, + codeChallenge: 'challenge', + }); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + body: {}, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(mockOAuthStateClientInstance.create).toHaveBeenCalledWith( + expect.objectContaining({ + kibanaReturnUrl: `${KIBANA_URL}/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`, + }) + ); + }); + + it('returns error on OAuth service failure', async () => { + mockOAuthServiceInstance.getOAuthConfig.mockRejectedValue( + new Error('Connector does not use OAuth Authorization Code flow') + ); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + body: {}, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.customError).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 500, + body: { + message: 'Connector does not use OAuth Authorization Code flow', + }, + }) + ); + }); + + it('preserves statusCode from errors that have one', async () => { + const notFoundError = new Error('Connector not found') as Error & { statusCode: number }; + notFoundError.statusCode = 404; + mockOAuthServiceInstance.getOAuthConfig.mockRejectedValue(notFoundError); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + body: {}, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.customError).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 404, + body: { + message: 'Connector not found', + }, + }) + ); + }); + + it('calls verifyAccessAndContext with the license state', () => { + const licenseState = licenseStateMock.create(); + oauthAuthorizeRoute( + router, + licenseState, + mockLogger, + createMockCoreSetup() as never, + mockRateLimiter as never + ); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts new file mode 100644 index 0000000000000..b894609778bd1 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts @@ -0,0 +1,434 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('./verify_access_and_context', () => ({ + verifyAccessAndContext: jest.fn(), +})); +jest.mock('../lib/oauth_state_client'); +jest.mock('../lib/connector_token_client'); +jest.mock('../lib/request_oauth_authorization_code_token'); + +import { httpServiceMock, httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { actionsConfigMock } from '../actions_config.mock'; +import { verifyAccessAndContext } from './verify_access_and_context'; +import { oauthCallbackRoute } from './oauth_callback'; +import { OAuthStateClient } from '../lib/oauth_state_client'; +import { ConnectorTokenClient } from '../lib/connector_token_client'; +import { requestOAuthAuthorizationCodeToken } from '../lib/request_oauth_authorization_code_token'; + +const KIBANA_URL = 'https://kibana.example.com'; + +const MockOAuthStateClient = OAuthStateClient as jest.MockedClass; +const MockConnectorTokenClient = ConnectorTokenClient as jest.MockedClass< + typeof ConnectorTokenClient +>; +const mockRequestOAuthAuthorizationCodeToken = + requestOAuthAuthorizationCodeToken as jest.MockedFunction< + typeof requestOAuthAuthorizationCodeToken + >; + +const configurationUtilities = actionsConfigMock.create(); +const mockLogger = loggingSystemMock.create().get(); + +const mockOAuthStateClientInstance = { + get: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + cleanupExpiredStates: jest.fn(), +}; + +const mockConnectorTokenClientInstance = { + deleteConnectorTokens: jest.fn(), + createWithRefreshToken: jest.fn(), +}; + +const mockEncryptedSavedObjectsClient = { + getClient: jest.fn().mockReturnValue({ + getDecryptedAsInternalUser: jest.fn(), + }), +}; + +const mockSpacesService = { + getSpaceId: jest.fn().mockReturnValue('default'), + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), +}; + +const mockRateLimiter = { + log: jest.fn(), + isRateLimited: jest.fn().mockReturnValue(false), + getLogs: jest.fn(), +}; + +const createMockCoreSetup = () => ({ + getStartServices: jest.fn().mockResolvedValue([ + { + http: { + basePath: { + publicBaseUrl: KIBANA_URL, + }, + }, + }, + { + encryptedSavedObjects: mockEncryptedSavedObjectsClient, + spaces: { spacesService: mockSpacesService }, + }, + ]), +}); + +const createMockContext = ( + currentUser: { username: string } | null = { username: 'testuser' } +) => ({ + core: Promise.resolve({ + security: { + authc: { + getCurrentUser: jest.fn().mockReturnValue(currentUser), + }, + }, + savedObjects: { + getClient: jest.fn().mockReturnValue({}), + }, + }), + actions: Promise.resolve({ + getActionsClient: jest.fn(), + }), +}); + +describe('oauthCallbackRoute', () => { + let router: ReturnType; + + beforeEach(() => { + jest.resetAllMocks(); + router = httpServiceMock.createRouter(); + (verifyAccessAndContext as jest.Mock).mockImplementation((_license, handler) => handler); + + // Restore mock implementations cleared by resetAllMocks + (mockLogger.get as jest.Mock).mockReturnValue(mockLogger); + mockRateLimiter.isRateLimited.mockReturnValue(false); + mockSpacesService.spaceIdToNamespace.mockReturnValue(undefined); + mockEncryptedSavedObjectsClient.getClient.mockReturnValue({ + getDecryptedAsInternalUser: jest.fn(), + }); + + MockOAuthStateClient.mockImplementation(() => mockOAuthStateClientInstance as never); + MockConnectorTokenClient.mockImplementation(() => mockConnectorTokenClientInstance as never); + }); + + const registerRoute = (coreSetup = createMockCoreSetup()) => { + const licenseState = licenseStateMock.create(); + oauthCallbackRoute( + router, + licenseState, + configurationUtilities, + mockLogger, + coreSetup as never, + mockRateLimiter as never + ); + return router.get.mock.calls[0]; + }; + + it('registers a GET route at the correct path', () => { + registerRoute(); + + const [config] = router.get.mock.calls[0]; + expect(config.path).toBe('/api/actions/connector/_oauth_callback'); + }); + + it('returns unauthorized when no current user', async () => { + const [, handler] = registerRoute(); + const context = createMockContext(null); + const req = httpServerMock.createKibanaRequest({ query: { code: 'abc', state: 'xyz' } }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.unauthorized).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { 'content-type': 'text/html' }, + }) + ); + }); + + it('returns rate limit page when rate limited', async () => { + mockRateLimiter.isRateLimited.mockReturnValue(true); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ query: { code: 'abc', state: 'xyz' } }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(mockRateLimiter.log).toHaveBeenCalledWith('testuser', 'callback'); + expect(res.ok).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { 'content-type': 'text/html' }, + body: expect.stringContaining('Too Many Requests'), + }) + ); + }); + + it('returns error page when OAuth error parameter is present', async () => { + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + query: { error: 'access_denied', error_description: 'User cancelled' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.ok).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { 'content-type': 'text/html' }, + body: expect.stringContaining('Authorization Failed'), + }) + ); + }); + + it('returns error page when code is missing', async () => { + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + query: { state: 'some-state' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.ok).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { 'content-type': 'text/html' }, + body: expect.stringContaining('Authorization Failed'), + }) + ); + }); + + it('returns error page when state is missing', async () => { + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + query: { code: 'auth-code' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.ok).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { 'content-type': 'text/html' }, + body: expect.stringContaining('Authorization Failed'), + }) + ); + }); + + it('returns error page for invalid/not found state', async () => { + mockOAuthStateClientInstance.get.mockResolvedValue(null); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + query: { code: 'auth-code', state: 'invalid-state' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.ok).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('Invalid or expired state parameter'), + }) + ); + }); + + it('exchanges code for tokens and redirects on success', async () => { + const mockOAuthState = { + id: 'state-id', + state: 'valid-state', + codeVerifier: 'test-verifier', + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + createdAt: '2025-01-01T00:00:00.000Z', + expiresAt: '2025-01-01T00:10:00.000Z', + }; + mockOAuthStateClientInstance.get.mockResolvedValue(mockOAuthState); + + const connectorEncryptedClient = { + getDecryptedAsInternalUser: jest.fn().mockResolvedValue({ + attributes: { + config: { tokenUrl: 'https://provider.example.com/token' }, + secrets: { + clientId: 'client-id', + clientSecret: 'client-secret', + tokenUrl: 'https://provider.example.com/token', + }, + }, + }), + }; + mockEncryptedSavedObjectsClient.getClient.mockReturnValue(connectorEncryptedClient); + + mockRequestOAuthAuthorizationCodeToken.mockResolvedValue({ + tokenType: 'Bearer', + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresIn: 3600, + }); + + mockConnectorTokenClientInstance.deleteConnectorTokens.mockResolvedValue(undefined); + mockConnectorTokenClientInstance.createWithRefreshToken.mockResolvedValue(undefined); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + query: { code: 'auth-code', state: 'valid-state' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + // Verify token exchange + expect(mockRequestOAuthAuthorizationCodeToken).toHaveBeenCalledWith( + 'https://provider.example.com/token', + mockLogger, + expect.objectContaining({ + code: 'auth-code', + redirectUri: `${KIBANA_URL}/api/actions/connector/_oauth_callback`, + codeVerifier: mockOAuthState.codeVerifier, + clientId: 'client-id', + clientSecret: 'client-secret', + }), + configurationUtilities, + true + ); + + // Verify token storage + expect(mockConnectorTokenClientInstance.deleteConnectorTokens).toHaveBeenCalledWith({ + connectorId: 'connector-1', + tokenType: 'access_token', + }); + expect(mockConnectorTokenClientInstance.createWithRefreshToken).toHaveBeenCalledWith({ + connectorId: 'connector-1', + accessToken: 'Bearer new-access-token', + refreshToken: 'new-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: undefined, + tokenType: 'access_token', + }); + + // Verify state cleanup + expect(mockOAuthStateClientInstance.delete).toHaveBeenCalledWith('state-id'); + + // Verify redirect + expect(res.redirected).toHaveBeenCalledWith({ + headers: { + location: 'https://kibana.example.com/app/connectors?oauth_authorization=success', + }, + }); + }); + + it('returns error page on token exchange failure', async () => { + const mockOAuthState = { + id: 'state-id', + state: 'valid-state', + codeVerifier: 'test-verifier', + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + createdAt: '2025-01-01T00:00:00.000Z', + expiresAt: '2025-01-01T00:10:00.000Z', + }; + mockOAuthStateClientInstance.get.mockResolvedValue(mockOAuthState); + + const connectorEncryptedClient = { + getDecryptedAsInternalUser: jest.fn().mockResolvedValue({ + attributes: { + config: {}, + secrets: { + clientId: 'client-id', + clientSecret: 'client-secret', + tokenUrl: 'https://provider.example.com/token', + }, + }, + }), + }; + mockEncryptedSavedObjectsClient.getClient.mockReturnValue(connectorEncryptedClient); + + mockRequestOAuthAuthorizationCodeToken.mockRejectedValue(new Error('Token exchange failed')); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + query: { code: 'auth-code', state: 'valid-state' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.ok).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { 'content-type': 'text/html' }, + body: expect.stringContaining('Token exchange failed'), + }) + ); + }); + + it('returns error page when connector is missing required OAuth config', async () => { + const mockOAuthState = { + id: 'state-id', + state: 'valid-state', + codeVerifier: 'test-verifier', + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + createdAt: '2025-01-01T00:00:00.000Z', + expiresAt: '2025-01-01T00:10:00.000Z', + }; + mockOAuthStateClientInstance.get.mockResolvedValue(mockOAuthState); + + const connectorEncryptedClient = { + getDecryptedAsInternalUser: jest.fn().mockResolvedValue({ + attributes: { + config: {}, + secrets: { clientId: 'client-id' }, // missing clientSecret and tokenUrl + }, + }), + }; + mockEncryptedSavedObjectsClient.getClient.mockReturnValue(connectorEncryptedClient); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + query: { code: 'auth-code', state: 'valid-state' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.ok).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining( + 'Connector missing required OAuth configuration (clientId, clientSecret, tokenUrl)' + ), + }) + ); + }); + + it('calls verifyAccessAndContext with the license state', () => { + const licenseState = licenseStateMock.create(); + oauthCallbackRoute( + router, + licenseState, + configurationUtilities, + mockLogger, + createMockCoreSetup() as never, + mockRateLimiter as never + ); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); +}); diff --git a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts index 6e20660961792..f773ac031693e 100644 --- a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts +++ b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts @@ -110,6 +110,7 @@ describe('checking changes on all registered encrypted SO types', () => { expect(modelVersionMap).toMatchInlineSnapshot(` Array [ + "action|2", "action|1", "action_task_params|2", "action_task_params|1",