Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ describe('useInstallEntityStoreV2', () => {
const mockServices = createMockServices();
mockServices.uiSettings.get.mockReturnValue(true);
mockServices.spaces.getActiveSpace.mockResolvedValue({ id: 'custom-space' });
mockServices.http.get.mockResolvedValueOnce({ status: EntityStoreStatus.enum.not_installed });
mockServices.http.get
.mockResolvedValueOnce({ status: EntityStoreStatus.enum.not_installed })
.mockResolvedValueOnce({ status: EntityStoreStatus.enum.not_installed });

renderHook(() => useInstallEntityStoreV2(asServices(mockServices)));

Expand All @@ -63,16 +65,20 @@ describe('useInstallEntityStoreV2', () => {
});
});

expect(mockServices.http.get).toHaveBeenCalledTimes(1);
expect(mockServices.http.get).toHaveBeenNthCalledWith(1, {
path: ENTITY_STORE_ROUTES.public.STATUS,
query: { include_components: false },
});
expect(mockServices.http.get).toHaveBeenCalledTimes(2);
expect(mockServices.http.post).not.toHaveBeenCalled();
});

it('should proceed when not in default space and v1 is installed', async () => {
it('should install when not in default space, v1 is installed, and v2 is not installed', async () => {
const mockServices = createMockServices();
mockServices.uiSettings.get.mockReturnValue(true);
mockServices.spaces.getActiveSpace.mockResolvedValue({ id: 'custom-space' });
mockServices.http.get
.mockResolvedValueOnce({ status: EntityStoreStatus.enum.running })
.mockResolvedValueOnce({ status: EntityStoreStatus.enum.not_installed })
.mockResolvedValueOnce({ status: EntityStoreStatus.enum.running });
mockServices.http.post.mockResolvedValueOnce({});

Expand All @@ -83,12 +89,37 @@ describe('useInstallEntityStoreV2', () => {
});

expect(mockServices.http.get).toHaveBeenNthCalledWith(1, {
path: '/api/entity_store/status',
});
expect(mockServices.http.get).toHaveBeenNthCalledWith(2, {
path: ENTITY_STORE_ROUTES.public.STATUS,
query: { include_components: false },
});
expect(mockServices.http.get).toHaveBeenNthCalledWith(2, {
path: '/api/entity_store/status',
});
expect(mockServices.http.post).toHaveBeenCalledWith({
path: ENTITY_STORE_ROUTES.public.INSTALL,
body: JSON.stringify({}),
});
});

it('should init entity maintainers when not in default space, v1 is not installed, and v2 is running', async () => {
const mockServices = createMockServices();
mockServices.uiSettings.get.mockReturnValue(true);
mockServices.spaces.getActiveSpace.mockResolvedValue({ id: 'custom-space' });
mockServices.http.get.mockResolvedValueOnce({ status: EntityStoreStatus.enum.running });
mockServices.http.post.mockResolvedValue({});

renderHook(() => useInstallEntityStoreV2(asServices(mockServices)));

await waitFor(() => {
expect(mockServices.http.post).toHaveBeenCalledTimes(1);
});

expect(mockServices.http.get).toHaveBeenCalledTimes(1);
expect(mockServices.http.post).toHaveBeenCalledWith({
path: ENTITY_STORE_ROUTES.internal.ENTITY_MAINTAINERS_INIT,
body: JSON.stringify({}),
query: { apiVersion: '2' },
});
});

it("should not install when entity store status is 'running'", async () => {
Expand Down Expand Up @@ -119,7 +150,7 @@ describe('useInstallEntityStoreV2', () => {
});
});

it('when entity store is not installed and space is default, installs entity store then inits entity maintainers', async () => {
it('when entity store is not installed and space is default, installs entity store (install API inits maintainers)', async () => {
const mockServices = createMockServices();
mockServices.uiSettings.get.mockReturnValue(true);
mockServices.spaces.getActiveSpace.mockResolvedValue({ id: 'default' });
Expand All @@ -129,18 +160,18 @@ describe('useInstallEntityStoreV2', () => {
renderHook(() => useInstallEntityStoreV2(asServices(mockServices)));

await waitFor(() => {
expect(mockServices.http.post).toHaveBeenCalledTimes(2);
expect(mockServices.http.post).toHaveBeenCalledTimes(1);
});

expect(mockServices.http.get).toHaveBeenCalledWith({
path: ENTITY_STORE_ROUTES.public.STATUS,
query: { include_components: false },
});
expect(mockServices.http.post).toHaveBeenNthCalledWith(1, {
expect(mockServices.http.post).toHaveBeenCalledWith({
path: ENTITY_STORE_ROUTES.public.INSTALL,
body: JSON.stringify({}),
});
expect(mockServices.http.post).toHaveBeenNthCalledWith(2, {
expect(mockServices.http.post).not.toHaveBeenCalledWith({
path: ENTITY_STORE_ROUTES.internal.ENTITY_MAINTAINERS_INIT,
body: JSON.stringify({}),
query: { apiVersion: '2' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,24 @@ export const useInstallEntityStoreV2 = (services: Services) => {
if (!isEntityStoreV2Enabled) return;

const space = await services.spaces.getActiveSpace();
// Install v2 and remove v1 in default namespace AND every namespace where v1 is currently installed
if (space.id !== 'default' && !(await isEntityStoreV1Installed(services.http))) return;

const statusResponse = await services.http.get<{ status: EntityStoreStatus }>(
getStatusRequest
);
const isEntityStoreV2Installed = isEntityStoreInstalled(statusResponse.status);
// In non-default spaces, only auto-install v2 where v1 existed. If v2 is already there,
// skip the v1 check and still run (e.g. init entity maintainers for this space).
if (space.id !== 'default' && !isEntityStoreV2Installed) {
if (!(await isEntityStoreV1Installed(services.http))) {
return;
}
}
// Entity store already installed → init entity maintainers only.
if (isEntityStoreV2Installed) {
await services.http.post(initEntityMaintainersRequest);
return;
}
// Entity store not installed → install entity store, then init entity maintainers.
// Entity store not installed → install entity store (init entity maintainers is already done by the install API).
await services.http.post(installAllEntitiesRequest);
await services.http.post(initEntityMaintainersRequest);
} catch (e) {
services.logger.error('Failed to initialize Entity Store V2');
services.logger.error(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ export function registerInstall(router: EntityStorePluginRouter) {
},
wrapMiddlewares(async (ctx, req, res): Promise<IKibanaResponse> => {
const entityStoreCtx = await ctx.entityStore;
const { logger, assetManagerClient: assetManager } = entityStoreCtx;
const {
logger,
assetManagerClient: assetManager,
entityMaintainersClient,
} = entityStoreCtx;
const { entityTypes, logExtraction, historySnapshot } = req.body;
logger.debug('Install api called');

Expand All @@ -60,6 +64,7 @@ export function registerInstall(router: EntityStorePluginRouter) {
}

await assetManager.init(req, toInstall, logExtraction, historySnapshot);
await entityMaintainersClient.init(req);
Comment thread
uri-weisman marked this conversation as resolved.

return res.created({ body: { ok: true } });
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,28 @@

import { apiTest } from '@kbn/scout-security';
import { expect } from '@kbn/scout-security/api';
import { PUBLIC_HEADERS, ENTITY_STORE_ROUTES, ENTITY_STORE_TAGS } from '../fixtures/constants';
import { FF_ENABLE_ENTITY_STORE_V2 } from '../../../../common';
import {
PUBLIC_HEADERS,
INTERNAL_HEADERS,
ENTITY_STORE_ROUTES,
ENTITY_STORE_TAGS,
} from '../fixtures/constants';
import { FF_ENABLE_ENTITY_STORE_V2, type GetEntityMaintainersResponse } from '../../../../common';

apiTest.describe('Entity Store install / update API tests', { tag: ENTITY_STORE_TAGS }, () => {
let defaultHeaders: Record<string, string>;
let internalHeaders: Record<string, string>;

apiTest.beforeAll(async ({ samlAuth }) => {
const credentials = await samlAuth.asInteractiveUser('admin');
defaultHeaders = {
...credentials.cookieHeader,
...PUBLIC_HEADERS,
};
internalHeaders = {
...credentials.cookieHeader,
...INTERNAL_HEADERS,
};
});

apiTest(
Expand All @@ -35,6 +45,18 @@ apiTest.describe('Entity Store install / update API tests', { tag: ENTITY_STORE_
});
expect(install.statusCode).toBe(201);

const maintainersResponse = await apiClient.get(
ENTITY_STORE_ROUTES.internal.ENTITY_MAINTAINERS_GET,
{
headers: internalHeaders,
responseType: 'json',
}
);
expect(maintainersResponse.statusCode).toBe(200);
const { maintainers } = maintainersResponse.body as GetEntityMaintainersResponse;
expect(maintainers.length).toBeGreaterThan(0);
expect(maintainers.every((m) => m.taskStatus === 'started')).toBe(true);

const uninstall = await apiClient.post(ENTITY_STORE_ROUTES.public.UNINSTALL, {
headers: defaultHeaders,
Comment on lines 60 to 61
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — Validate the response body

expect(maintainers.every(…)).toBe(true) reduces the whole array to a single boolean, so a failure says "expected false to be true" with no indication of which maintainer has a wrong status. Asserting per-element gives a much better diagnostic:

Suggested change
const uninstall = await apiClient.post(ENTITY_STORE_ROUTES.public.UNINSTALL, {
headers: defaultHeaders,
expect(maintainers.length).toBeGreaterThan(0);
for (const m of maintainers) {
expect(m.taskStatus).toBe('started');
}

Posted via Macroscope — Scout Test Review

responseType: 'json',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { ENTITY_STORE_ROUTES, API_VERSIONS } from '@kbn/entity-store/common';
import { ENTITY_RESOLUTION_CSV_UPLOAD_URL } from '@kbn/security-solution-plugin/common/entity_analytics/entity_store/constants';
import type { FtrProviderContext } from '../../../../ftr_provider_context';
import { EntityStoreUtils } from '../../utils';
import { entityMaintainerRouteHelpersFactory } from '../../utils/entity_maintainers';

/** Same value as `MAINTAINER_ID` in entity_store `server/maintainers/automated_resolution`. */
const AUTOMATED_RESOLUTION_MAINTAINER_ID = 'automated-resolution';

const TEST_PREFIX = 'csv-test:';

Expand Down Expand Up @@ -116,11 +120,14 @@ export default ({ getService }: FtrProviderContext) => {

describe('@ess @serverless @skipInServerlessMKI Entity Resolution CSV Upload', () => {
before(async () => {
// Use enableEntityStoreV2 (without maintainer init) to prevent the
// automated resolution maintainer from racing with CSV upload tests.
// Use enableEntityStoreV2 and explicitly stop
// the automated-resolution maintainer so it cannot race with CSV upload tests.
// The maintainer would link entities sharing the same user.email,
// interfering with the test's own resolution assertions.
await entityStoreUtils.enableEntityStoreV2();
await entityMaintainerRouteHelpersFactory(supertest).stopMaintainer(
AUTOMATED_RESOLUTION_MAINTAINER_ID
);
await cleanEntities();
await seedEntities();
await waitForEntities();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export default ({ getService }: FtrProviderContext): void => {
startMaintainer: async () => {
throw new Error('Preview API tests do not use maintainer route startMaintainer');
},
stopMaintainer: async () => {
throw new Error('Preview API tests do not use maintainer route stopMaintainer');
},
},
});
const setEntityStoreV2Setting = async (enabled: boolean) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ interface RetryServiceLike {

type MaintainerRoutesLike = Pick<
ReturnType<typeof entityMaintainerRouteHelpersFactory>,
'getMaintainers' | 'runMaintainer' | 'runMaintainerSync' | 'startMaintainer'
'getMaintainers' | 'runMaintainer' | 'runMaintainerSync' | 'startMaintainer' | 'stopMaintainer'
>;

interface EntityStoreUtilsLike {
Expand Down Expand Up @@ -364,6 +364,8 @@ export const riskScoreMaintainerScenarioFactory = ({
dataViewPattern,
maintainerAutoStart: false,
});
await routes.stopMaintainer('risk-score');

if (runMode === 'sync') {
await routes.runMaintainerSync('risk-score');
return;
Expand Down
Loading