Skip to content

Commit 69844e4

Browse files
[ML] Add API integration testing for AD annotations (#73068)
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
1 parent b0d51c8 commit 69844e4

File tree

8 files changed

+648
-0
lines changed

8 files changed

+648
-0
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { ANNOTATION_TYPE } from '../../../../../plugins/ml/common/constants/annotations';
8+
import { Annotation } from '../../../../../plugins/ml/common/types/annotations';
9+
10+
export const commonJobConfig = {
11+
description: 'test_job_annotation',
12+
groups: ['farequote', 'automated', 'single-metric'],
13+
analysis_config: {
14+
bucket_span: '15m',
15+
influencers: [],
16+
detectors: [
17+
{
18+
function: 'mean',
19+
field_name: 'responsetime',
20+
},
21+
{
22+
function: 'min',
23+
field_name: 'responsetime',
24+
},
25+
],
26+
},
27+
data_description: { time_field: '@timestamp' },
28+
analysis_limits: { model_memory_limit: '10mb' },
29+
};
30+
31+
export const createJobConfig = (jobId: string) => {
32+
return { ...commonJobConfig, job_id: jobId };
33+
};
34+
35+
export const testSetupJobConfigs = [1, 2, 3, 4].map((num) => ({
36+
...commonJobConfig,
37+
job_id: `job_annotation_${num}_${Date.now()}`,
38+
description: `Test annotation ${num}`,
39+
}));
40+
export const jobIds = testSetupJobConfigs.map((j) => j.job_id);
41+
42+
export const createAnnotationRequestBody = (jobId: string): Partial<Annotation> => {
43+
return {
44+
timestamp: Date.now(),
45+
end_timestamp: Date.now(),
46+
annotation: 'Test annotation',
47+
job_id: jobId,
48+
type: ANNOTATION_TYPE.ANNOTATION,
49+
event: 'user',
50+
detector_index: 1,
51+
partition_field_name: 'airline',
52+
partition_field_value: 'AAL',
53+
};
54+
};
55+
56+
export const testSetupAnnotations = testSetupJobConfigs.map((job) =>
57+
createAnnotationRequestBody(job.job_id)
58+
);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import expect from '@kbn/expect';
8+
9+
import { FtrProviderContext } from '../../../ftr_provider_context';
10+
import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common';
11+
import { USER } from '../../../../functional/services/ml/security_common';
12+
import { Annotation } from '../../../../../plugins/ml/common/types/annotations';
13+
import { createJobConfig, createAnnotationRequestBody } from './common_jobs';
14+
// eslint-disable-next-line import/no-default-export
15+
export default ({ getService }: FtrProviderContext) => {
16+
const esArchiver = getService('esArchiver');
17+
const supertest = getService('supertestWithoutAuth');
18+
const ml = getService('ml');
19+
20+
const jobId = `job_annotation_${Date.now()}`;
21+
const testJobConfig = createJobConfig(jobId);
22+
const annotationRequestBody = createAnnotationRequestBody(jobId);
23+
24+
describe('create_annotations', function () {
25+
before(async () => {
26+
await esArchiver.loadIfNeeded('ml/farequote');
27+
await ml.testResources.setKibanaTimeZoneToUTC();
28+
await ml.api.createAnomalyDetectionJob(testJobConfig);
29+
});
30+
31+
after(async () => {
32+
await ml.api.cleanMlIndices();
33+
});
34+
35+
it('should successfully create annotations for anomaly job', async () => {
36+
const { body } = await supertest
37+
.put('/api/ml/annotations/index')
38+
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
39+
.set(COMMON_REQUEST_HEADERS)
40+
.send(annotationRequestBody)
41+
.expect(200);
42+
const annotationId = body._id;
43+
44+
const fetchedAnnotation = await ml.api.getAnnotationById(annotationId);
45+
46+
expect(fetchedAnnotation).to.not.be(undefined);
47+
48+
if (fetchedAnnotation) {
49+
Object.keys(annotationRequestBody).forEach((key) => {
50+
const field = key as keyof Annotation;
51+
expect(fetchedAnnotation[field]).to.eql(annotationRequestBody[field]);
52+
});
53+
}
54+
expect(fetchedAnnotation?.create_username).to.eql(USER.ML_POWERUSER);
55+
});
56+
57+
it('should successfully create annotation for user with ML read permissions', async () => {
58+
const { body } = await supertest
59+
.put('/api/ml/annotations/index')
60+
.auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
61+
.set(COMMON_REQUEST_HEADERS)
62+
.send(annotationRequestBody)
63+
.expect(200);
64+
65+
const annotationId = body._id;
66+
const fetchedAnnotation = await ml.api.getAnnotationById(annotationId);
67+
expect(fetchedAnnotation).to.not.be(undefined);
68+
if (fetchedAnnotation) {
69+
Object.keys(annotationRequestBody).forEach((key) => {
70+
const field = key as keyof Annotation;
71+
expect(fetchedAnnotation[field]).to.eql(annotationRequestBody[field]);
72+
});
73+
}
74+
expect(fetchedAnnotation?.create_username).to.eql(USER.ML_VIEWER);
75+
});
76+
77+
it('should not allow to create annotation for unauthorized user', async () => {
78+
const { body } = await supertest
79+
.put('/api/ml/annotations/index')
80+
.auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED))
81+
.set(COMMON_REQUEST_HEADERS)
82+
.send(annotationRequestBody)
83+
.expect(404);
84+
85+
expect(body.error).to.eql('Not Found');
86+
expect(body.message).to.eql('Not Found');
87+
});
88+
});
89+
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import expect from '@kbn/expect';
8+
import { FtrProviderContext } from '../../../ftr_provider_context';
9+
import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common';
10+
import { USER } from '../../../../functional/services/ml/security_common';
11+
import { testSetupJobConfigs, jobIds, testSetupAnnotations } from './common_jobs';
12+
13+
// eslint-disable-next-line import/no-default-export
14+
export default ({ getService }: FtrProviderContext) => {
15+
const esArchiver = getService('esArchiver');
16+
const supertest = getService('supertestWithoutAuth');
17+
const ml = getService('ml');
18+
19+
describe('delete_annotations', function () {
20+
before(async () => {
21+
await esArchiver.loadIfNeeded('ml/farequote');
22+
await ml.testResources.setKibanaTimeZoneToUTC();
23+
24+
// generate one annotation for each job
25+
for (let i = 0; i < testSetupJobConfigs.length; i++) {
26+
const job = testSetupJobConfigs[i];
27+
const annotationToIndex = testSetupAnnotations[i];
28+
await ml.api.createAnomalyDetectionJob(job);
29+
await ml.api.indexAnnotation(annotationToIndex);
30+
}
31+
});
32+
33+
after(async () => {
34+
await ml.api.cleanMlIndices();
35+
});
36+
37+
it('should delete annotation by id', async () => {
38+
const annotationsForJob = await ml.api.getAnnotations(jobIds[0]);
39+
expect(annotationsForJob).to.have.length(1);
40+
41+
const annotationIdToDelete = annotationsForJob[0]._id;
42+
43+
const { body } = await supertest
44+
.delete(`/api/ml/annotations/delete/${annotationIdToDelete}`)
45+
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
46+
.set(COMMON_REQUEST_HEADERS)
47+
.expect(200);
48+
49+
expect(body._id).to.eql(annotationIdToDelete);
50+
expect(body.result).to.eql('deleted');
51+
52+
await ml.api.waitForAnnotationNotToExist(annotationIdToDelete);
53+
});
54+
55+
it('should delete annotation by id for user with viewer permission', async () => {
56+
const annotationsForJob = await ml.api.getAnnotations(jobIds[1]);
57+
expect(annotationsForJob).to.have.length(1);
58+
59+
const annotationIdToDelete = annotationsForJob[0]._id;
60+
61+
const { body } = await supertest
62+
.delete(`/api/ml/annotations/delete/${annotationIdToDelete}`)
63+
.auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
64+
.set(COMMON_REQUEST_HEADERS)
65+
.expect(200);
66+
67+
expect(body._id).to.eql(annotationIdToDelete);
68+
expect(body.result).to.eql('deleted');
69+
70+
await ml.api.waitForAnnotationNotToExist(annotationIdToDelete);
71+
});
72+
73+
it('should not delete annotation for unauthorized user', async () => {
74+
const annotationsForJob = await ml.api.getAnnotations(jobIds[2]);
75+
expect(annotationsForJob).to.have.length(1);
76+
77+
const annotationIdToDelete = annotationsForJob[0]._id;
78+
79+
const { body } = await supertest
80+
.delete(`/api/ml/annotations/delete/${annotationIdToDelete}`)
81+
.auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED))
82+
.set(COMMON_REQUEST_HEADERS)
83+
.expect(404);
84+
85+
expect(body.error).to.eql('Not Found');
86+
expect(body.message).to.eql('Not Found');
87+
88+
await ml.api.waitForAnnotationToExist(annotationIdToDelete);
89+
});
90+
});
91+
};
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import expect from '@kbn/expect';
8+
import { omit } from 'lodash';
9+
import { FtrProviderContext } from '../../../ftr_provider_context';
10+
import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common';
11+
import { USER } from '../../../../functional/services/ml/security_common';
12+
import { testSetupJobConfigs, jobIds, testSetupAnnotations } from './common_jobs';
13+
14+
// eslint-disable-next-line import/no-default-export
15+
export default ({ getService }: FtrProviderContext) => {
16+
const esArchiver = getService('esArchiver');
17+
const supertest = getService('supertestWithoutAuth');
18+
const ml = getService('ml');
19+
20+
describe('get_annotations', function () {
21+
before(async () => {
22+
await esArchiver.loadIfNeeded('ml/farequote');
23+
await ml.testResources.setKibanaTimeZoneToUTC();
24+
25+
// generate one annotation for each job
26+
for (let i = 0; i < testSetupJobConfigs.length; i++) {
27+
const job = testSetupJobConfigs[i];
28+
const annotationToIndex = testSetupAnnotations[i];
29+
await ml.api.createAnomalyDetectionJob(job);
30+
await ml.api.indexAnnotation(annotationToIndex);
31+
}
32+
});
33+
34+
after(async () => {
35+
await ml.api.cleanMlIndices();
36+
});
37+
38+
it('should fetch all annotations for jobId', async () => {
39+
const requestBody = {
40+
jobIds: [jobIds[0]],
41+
earliestMs: 1454804100000,
42+
latestMs: Date.now(),
43+
maxAnnotations: 500,
44+
};
45+
const { body } = await supertest
46+
.post('/api/ml/annotations')
47+
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
48+
.set(COMMON_REQUEST_HEADERS)
49+
.send(requestBody)
50+
.expect(200);
51+
52+
expect(body.success).to.eql(true);
53+
expect(body.annotations).not.to.be(undefined);
54+
[jobIds[0]].forEach((jobId, idx) => {
55+
expect(body.annotations).to.have.property(jobId);
56+
expect(body.annotations[jobId]).to.have.length(1);
57+
58+
const indexedAnnotation = omit(body.annotations[jobId][0], '_id');
59+
expect(indexedAnnotation).to.eql(testSetupAnnotations[idx]);
60+
});
61+
});
62+
63+
it('should fetch all annotations for multiple jobs', async () => {
64+
const requestBody = {
65+
jobIds,
66+
earliestMs: 1454804100000,
67+
latestMs: Date.now(),
68+
maxAnnotations: 500,
69+
};
70+
const { body } = await supertest
71+
.post('/api/ml/annotations')
72+
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
73+
.set(COMMON_REQUEST_HEADERS)
74+
.send(requestBody)
75+
.expect(200);
76+
77+
expect(body.success).to.eql(true);
78+
expect(body.annotations).not.to.be(undefined);
79+
jobIds.forEach((jobId, idx) => {
80+
expect(body.annotations).to.have.property(jobId);
81+
expect(body.annotations[jobId]).to.have.length(1);
82+
83+
const indexedAnnotation = omit(body.annotations[jobId][0], '_id');
84+
expect(indexedAnnotation).to.eql(testSetupAnnotations[idx]);
85+
});
86+
});
87+
88+
it('should fetch all annotations for user with ML read permissions', async () => {
89+
const requestBody = {
90+
jobIds: testSetupJobConfigs.map((j) => j.job_id),
91+
earliestMs: 1454804100000,
92+
latestMs: Date.now(),
93+
maxAnnotations: 500,
94+
};
95+
const { body } = await supertest
96+
.post('/api/ml/annotations')
97+
.auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
98+
.set(COMMON_REQUEST_HEADERS)
99+
.send(requestBody)
100+
.expect(200);
101+
expect(body.success).to.eql(true);
102+
expect(body.annotations).not.to.be(undefined);
103+
jobIds.forEach((jobId, idx) => {
104+
expect(body.annotations).to.have.property(jobId);
105+
expect(body.annotations[jobId]).to.have.length(1);
106+
107+
const indexedAnnotation = omit(body.annotations[jobId][0], '_id');
108+
expect(indexedAnnotation).to.eql(testSetupAnnotations[idx]);
109+
});
110+
});
111+
112+
it('should not allow to fetch annotation for unauthorized user', async () => {
113+
const requestBody = {
114+
jobIds: testSetupJobConfigs.map((j) => j.job_id),
115+
earliestMs: 1454804100000,
116+
latestMs: Date.now(),
117+
maxAnnotations: 500,
118+
};
119+
const { body } = await supertest
120+
.post('/api/ml/annotations')
121+
.auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED))
122+
.set(COMMON_REQUEST_HEADERS)
123+
.send(requestBody)
124+
.expect(404);
125+
126+
expect(body.error).to.eql('Not Found');
127+
expect(body.message).to.eql('Not Found');
128+
});
129+
});
130+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { FtrProviderContext } from '../../../ftr_provider_context';
8+
9+
export default function ({ loadTestFile }: FtrProviderContext) {
10+
describe('annotations', function () {
11+
loadTestFile(require.resolve('./create_annotations'));
12+
loadTestFile(require.resolve('./get_annotations'));
13+
loadTestFile(require.resolve('./delete_annotations'));
14+
loadTestFile(require.resolve('./update_annotations'));
15+
});
16+
}

0 commit comments

Comments
 (0)