[Rules migration][Integration test] Update migration API (#11232) (#211196)

## Summary

[Internal link](https://github.com/elastic/security-team/issues/10820)
to the feature details

Part of https://github.com/elastic/security-team/issues/11232

This PR covers SIEM Migrations Update API (route: `PUT
/internal/siem_migrations/rules/{migration_id}`) integration test:
* Happy path
  * update migration
  * ignore attributes that are not eligible for update
* Error handling
  * an empty content response
  * an error when rule's id is not specified
  * an error when undefined payload has been passed

Also, as part of this PR, I added error handling cases for Create API:
* no content error
* an error when undefined payload has been passed
* an error when original rule id is not specified
* error when original rule vendor is not specified
* an error when original rule title is not specified
* an error when original rule description is not specified
* an error when original rule query is not specified
* an error when original rule query_language is not specified

---------

Co-authored-by: Sergi Massaneda <sergi.massaneda@gmail.com>
This commit is contained in:
Ievgen Sorokopud 2025-02-14 18:39:21 +01:00 committed by GitHub
parent 20aac5c915
commit 819fd7a3e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 350 additions and 71 deletions

View file

@ -44,6 +44,9 @@ export const registerSiemRuleMigrationsUpdateRoute = (
const { migration_id: migrationId } = req.params;
const rulesToUpdate = req.body;
if (rulesToUpdate.length === 0) {
return res.noContent();
}
const ids = rulesToUpdate.map((rule) => rule.id);
const siemMigrationAuditLogger = new SiemMigrationAuditLogger(context.securitySolution);

View file

@ -28,78 +28,171 @@ export default ({ getService }: FtrProviderContext) => {
await deleteAllMigrationRules(es);
});
it('should create migrations with provided id', async () => {
const migrationId = uuidv4();
await migrationRulesRoutes.create({ migrationId, body: [defaultOriginalRule] });
describe('Happy path', () => {
it('should create migrations with provided id', async () => {
const migrationId = uuidv4();
await migrationRulesRoutes.create({ migrationId, payload: [defaultOriginalRule] });
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(1);
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(1);
const migrationRule = response.body.data[0];
expect(migrationRule).toEqual(
expect.objectContaining({
migration_id: migrationId,
original_rule: defaultOriginalRule,
status: SiemMigrationStatus.PENDING,
})
);
});
it('should create migrations without provided id', async () => {
const {
body: { migration_id: migrationId },
} = await migrationRulesRoutes.create({ body: [defaultOriginalRule] });
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(1);
const migrationRule = response.body.data[0];
expect(migrationRule).toEqual(
expect.objectContaining({
migration_id: migrationId,
original_rule: defaultOriginalRule,
status: SiemMigrationStatus.PENDING,
})
);
});
it('should create migrations with the rules that have resources', async () => {
const migrationId = uuidv4();
await migrationRulesRoutes.create({ migrationId, body: [splunkRuleWithResources] });
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(1);
const migrationRule = response.body.data[0];
expect(migrationRule).toEqual(
expect.objectContaining({
migration_id: migrationId,
original_rule: splunkRuleWithResources,
status: SiemMigrationStatus.PENDING,
})
);
// fetch missing resources
const resourcesResponse = await migrationResourcesRoutes.getMissingResources({
migrationId,
const migrationRule = response.body.data[0];
expect(migrationRule).toEqual(
expect.objectContaining({
migration_id: migrationId,
original_rule: defaultOriginalRule,
status: SiemMigrationStatus.PENDING,
})
);
});
it('should create migrations without provided id', async () => {
const {
body: { migration_id: migrationId },
} = await migrationRulesRoutes.create({ payload: [defaultOriginalRule] });
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(1);
const migrationRule = response.body.data[0];
expect(migrationRule).toEqual(
expect.objectContaining({
migration_id: migrationId,
original_rule: defaultOriginalRule,
status: SiemMigrationStatus.PENDING,
})
);
});
it('should create migrations with the rules that have resources', async () => {
const migrationId = uuidv4();
await migrationRulesRoutes.create({ migrationId, payload: [splunkRuleWithResources] });
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(1);
const migrationRule = response.body.data[0];
expect(migrationRule).toEqual(
expect.objectContaining({
migration_id: migrationId,
original_rule: splunkRuleWithResources,
status: SiemMigrationStatus.PENDING,
})
);
// fetch missing resources
const resourcesResponse = await migrationResourcesRoutes.getMissingResources({
migrationId,
});
expect(resourcesResponse.body).toEqual([
{ type: 'macro', name: 'summariesonly' },
{ type: 'macro', name: 'drop_dm_object_name(1)' },
{ type: 'lookup', name: 'malware_tracker' },
]);
});
expect(resourcesResponse.body).toEqual([
{ type: 'macro', name: 'summariesonly' },
{ type: 'macro', name: 'drop_dm_object_name(1)' },
{ type: 'lookup', name: 'malware_tracker' },
]);
});
it('should return no content error', async () => {
const migrationId = uuidv4();
await migrationRulesRoutes.create({ migrationId, body: [], expectStatusCode: 204 });
describe('Error handling', () => {
it('should return no content error', async () => {
const migrationId = uuidv4();
await migrationRulesRoutes.create({ migrationId, payload: [], expectStatusCode: 204 });
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(0);
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(0);
});
it(`should return an error when undefined payload has been passed`, async () => {
const migrationId = uuidv4();
const response = await migrationRulesRoutes.create({ migrationId, expectStatusCode: 400 });
expect(response.body).toEqual({
error: 'Bad Request',
message: '[request body]: Expected array, received null',
statusCode: 400,
});
});
it('should return an error when original rule id is not specified', async () => {
const { id, ...restOfOriginalRule } = defaultOriginalRule;
const response = await migrationRulesRoutes.create({
payload: [restOfOriginalRule],
expectStatusCode: 400,
});
expect(response.body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: '[request body]: 0.id: Required',
});
});
it('should return an error when original rule vendor is not specified', async () => {
const { vendor, ...restOfOriginalRule } = defaultOriginalRule;
const response = await migrationRulesRoutes.create({
payload: [restOfOriginalRule],
expectStatusCode: 400,
});
expect(response.body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: '[request body]: 0.vendor: Invalid literal value, expected "splunk"',
});
});
it('should return an error when original rule title is not specified', async () => {
const { title, ...restOfOriginalRule } = defaultOriginalRule;
const response = await migrationRulesRoutes.create({
payload: [restOfOriginalRule],
expectStatusCode: 400,
});
expect(response.body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: '[request body]: 0.title: Required',
});
});
it('should return an error when original rule description is not specified', async () => {
const { description, ...restOfOriginalRule } = defaultOriginalRule;
const response = await migrationRulesRoutes.create({
payload: [restOfOriginalRule],
expectStatusCode: 400,
});
expect(response.body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: '[request body]: 0.description: Required',
});
});
it('should return an error when original rule query is not specified', async () => {
const { query, ...restOfOriginalRule } = defaultOriginalRule;
const response = await migrationRulesRoutes.create({
payload: [restOfOriginalRule],
expectStatusCode: 400,
});
expect(response.body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: '[request body]: 0.query: Required',
});
});
it('should return an error when original rule query_language is not specified', async () => {
const { query_language: _, ...restOfOriginalRule } = defaultOriginalRule;
const response = await migrationRulesRoutes.create({
payload: [restOfOriginalRule],
expectStatusCode: 400,
});
expect(response.body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: '[request body]: 0.query_language: Required',
});
});
});
});
};

View file

@ -10,5 +10,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('@ess SecuritySolution SIEM Migrations', () => {
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./update'));
});
}

View file

@ -0,0 +1,156 @@
/*
* 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 expect from 'expect';
import { v4 as uuidv4 } from 'uuid';
import {
createMigrationRules,
deleteAllMigrationRules,
getMigrationRuleDocument,
migrationRulesRouteHelpersFactory,
} from '../../utils';
import { FtrProviderContext } from '../../../../ftr_provider_context';
export default ({ getService }: FtrProviderContext) => {
const es = getService('es');
const supertest = getService('supertest');
const migrationRulesRoutes = migrationRulesRouteHelpersFactory(supertest);
describe('@ess @serverless @serverlessQA Update API', () => {
beforeEach(async () => {
await deleteAllMigrationRules(es);
});
describe('Happy path', () => {
it('should update migration rules', async () => {
const migrationId = uuidv4();
const migrationRuleDocument = getMigrationRuleDocument({ migration_id: migrationId });
const [createdDocumentId] = await createMigrationRules(es, [migrationRuleDocument]);
const now = new Date().toISOString();
await migrationRulesRoutes.update({
migrationId,
payload: [
{
id: createdDocumentId,
elastic_rule: { title: 'Updated title' },
comments: [{ message: 'Update comment', created_by: 'ftr test', created_at: now }],
},
],
});
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(1);
const {
'@timestamp': timestamp,
updated_at: updatedAt,
updated_by: updatedBy,
elastic_rule: elasticRule,
...rest
} = migrationRuleDocument;
const migrationRule = response.body.data[0];
expect(migrationRule).toEqual(
expect.objectContaining({
...rest,
elastic_rule: { ...elasticRule, title: 'Updated title' },
comments: [{ message: 'Update comment', created_by: 'ftr test', created_at: now }],
})
);
});
it('should ignore attributes that are not eligible for update', async () => {
const migrationId = uuidv4();
const migrationRuleDocument = getMigrationRuleDocument({ migration_id: migrationId });
const [createdDocumentId] = await createMigrationRules(es, [migrationRuleDocument]);
const now = new Date().toISOString();
await migrationRulesRoutes.update({
migrationId,
payload: [
{
id: createdDocumentId,
elastic_rule: { title: 'Updated title' },
comments: [{ message: 'Update comment', created_by: 'ftr test', created_at: now }],
// Should be ignored
migration_id: 'fake_migration_id_1',
original_rule: { description: 'Ignore this description' },
translation_result: 'ignore this translation result',
status: 'ignore this status',
},
],
});
const {
'@timestamp': timestamp,
updated_at: updatedAt,
updated_by: updatedBy,
elastic_rule: elasticRule,
...rest
} = migrationRuleDocument;
const expectedMigrationRule = expect.objectContaining({
...rest,
elastic_rule: { ...elasticRule, title: 'Updated title' },
comments: [{ message: 'Update comment', created_by: 'ftr test', created_at: now }],
});
// fetch migration rule
const response = await migrationRulesRoutes.get({ migrationId });
expect(response.body.total).toEqual(1);
const migrationRule = response.body.data[0];
expect(migrationRule).toEqual(expectedMigrationRule);
});
});
describe('Error handling', () => {
it('should return empty content response when no rules passed', async () => {
const migrationId = uuidv4();
await migrationRulesRoutes.update({
migrationId,
payload: [],
expectStatusCode: 204,
});
});
it(`should return an error when rule's id is not specified`, async () => {
const migrationId = uuidv4();
const migrationRuleDocument = getMigrationRuleDocument({ migration_id: migrationId });
await createMigrationRules(es, [migrationRuleDocument]);
const response = await migrationRulesRoutes.update({
migrationId,
payload: [{ elastic_rule: { title: 'Updated title' } }],
expectStatusCode: 400,
});
expect(response.body).toEqual({
error: 'Bad Request',
message: '[request body]: 0.id: Required',
statusCode: 400,
});
});
it(`should return an error when undefined payload has been passed`, async () => {
const migrationId = uuidv4();
const migrationRuleDocument = getMigrationRuleDocument({ migration_id: migrationId });
await createMigrationRules(es, [migrationRuleDocument]);
const response = await migrationRulesRoutes.update({
migrationId,
expectStatusCode: 400,
});
expect(response.body).toEqual({
error: 'Bad Request',
message: '[request body]: Expected array, received null',
statusCode: 400,
});
});
});
});
};

View file

@ -17,10 +17,10 @@ import {
SIEM_RULE_MIGRATION_PATH,
} from '@kbn/security-solution-plugin/common/siem_migrations/constants';
import {
CreateRuleMigrationRequestBody,
CreateRuleMigrationResponse,
GetRuleMigrationRequestQuery,
GetRuleMigrationResponse,
UpdateRuleMigrationResponse,
} from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen';
import { API_VERSIONS } from '@kbn/security-solution-plugin/common/constants';
import { assertStatusCode } from './asserts';
@ -38,8 +38,17 @@ export interface CreateRuleMigrationParams {
/** Optional `id` of migration to add the rules to.
* The id is necessary only for batching the migration creation in multiple requests */
migrationId?: string;
/** The body containing the `connectorId` to use for the migration */
body: CreateRuleMigrationRequestBody;
/** Optional payload to send */
payload?: any;
/** Optional expected status code parameter */
expectStatusCode?: number;
}
export interface UpdateRulesParams {
/** `id` of the migration to install rules for */
migrationId: string;
/** Optional payload to send */
payload?: any;
/** Optional expected status code parameter */
expectStatusCode?: number;
}
@ -66,7 +75,7 @@ export const migrationRulesRouteHelpersFactory = (supertest: SuperTest.Agent) =>
create: async ({
migrationId,
body,
payload,
expectStatusCode = 200,
}: CreateRuleMigrationParams): Promise<{ body: CreateRuleMigrationResponse }> => {
const response = await supertest
@ -74,7 +83,24 @@ export const migrationRulesRouteHelpersFactory = (supertest: SuperTest.Agent) =>
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1)
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send(body);
.send(payload);
assertStatusCode(expectStatusCode, response);
return response;
},
update: async ({
migrationId,
payload,
expectStatusCode = 200,
}: UpdateRulesParams): Promise<{ body: UpdateRuleMigrationResponse }> => {
const response = await supertest
.put(replaceParams(SIEM_RULE_MIGRATION_PATH, { migration_id: migrationId }))
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1)
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send(payload);
assertStatusCode(expectStatusCode, response);