[Cases] Add decode to attachment functionality (#158424)

This PR adds io-ts decode calls to the service layer of the attachment
functionality. This ensures that the results from elasticsearch do not
violate the schema.

For bulk operations, error saved objects are not decoded.
This commit is contained in:
Jonathan Buttner 2023-05-26 10:00:57 -04:00 committed by GitHub
parent af5e805491
commit 97e122f16d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 807 additions and 80 deletions

View file

@ -126,7 +126,10 @@ export const PersistableStateAttachmentRt = rt.strict({
});
const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]);
const AttributesTypeAlertsRt = rt.intersection([AlertCommentRequestRt, CommentAttributesBasicRt]);
export const AttributesTypeAlertsRt = rt.intersection([
AlertCommentRequestRt,
CommentAttributesBasicRt,
]);
const AttributesTypeActionsRt = rt.intersection([
ActionsCommentRequestRt,
CommentAttributesBasicRt,
@ -137,6 +140,11 @@ const AttributesTypeExternalReferenceRt = rt.intersection([
CommentAttributesBasicRt,
]);
const AttributesTypeExternalReferenceWithoutRefsRt = rt.intersection([
ExternalReferenceWithoutRefsRt,
CommentAttributesBasicRt,
]);
const AttributesTypeExternalReferenceNoSORt = rt.intersection([
ExternalReferenceNoSORt,
CommentAttributesBasicRt,
@ -152,7 +160,7 @@ const AttributesTypePersistableStateRt = rt.intersection([
CommentAttributesBasicRt,
]);
const CommentAttributesRt = rt.union([
export const CommentAttributesRt = rt.union([
AttributesTypeUserRt,
AttributesTypeAlertsRt,
AttributesTypeActionsRt,
@ -172,7 +180,7 @@ const CommentAttributesWithoutRefsRt = rt.union([
AttributesTypeUserRt,
AttributesTypeAlertsRt,
AttributesTypeActionsRt,
ExternalReferenceWithoutRefsRt,
AttributesTypeExternalReferenceWithoutRefsRt,
AttributesTypePersistableStateRt,
]);
@ -326,6 +334,7 @@ export type AttributesTypeExternalReferenceSO = rt.TypeOf<
export type AttributesTypeExternalReferenceNoSO = rt.TypeOf<
typeof AttributesTypeExternalReferenceNoSORt
>;
export type ExternalReferenceWithoutRefs = rt.TypeOf<typeof ExternalReferenceWithoutRefsRt>;
export type AttributesTypePersistableState = rt.TypeOf<typeof AttributesTypePersistableStateRt>;
export type CommentAttributes = rt.TypeOf<typeof CommentAttributesRt>;
export type CommentAttributesNoSO = rt.TypeOf<typeof CommentAttributesNoSORt>;

View file

@ -8,6 +8,7 @@
import type { SavedObject } from '@kbn/core/server';
import type { JsonValue } from '@kbn/utility-types';
import type { CommentAttributes } from '../../../common/api';
import { CommentAttributesRt, CommentPatchAttributesRt } from '../../../common/api';
import type { User } from './user';
interface AttachmentCommonPersistedAttributes {
@ -51,3 +52,6 @@ export type AttachmentPersistedAttributes = AttachmentRequestAttributes &
export type AttachmentTransformedAttributes = CommentAttributes;
export type AttachmentSavedObjectTransformed = SavedObject<AttachmentTransformedAttributes>;
export const AttachmentTransformedAttributesRt = CommentAttributesRt;
export const AttachmentPartialAttributesRt = CommentPatchAttributesRt;

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { unset } from 'lodash';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { AttachmentService } from '.';
@ -19,8 +21,11 @@ import {
persistableStateAttachmentAttributes,
persistableStateAttachmentAttributesWithoutInjectedId,
} from '../../attachment_framework/mocks';
import { createAlertAttachment, createErrorSO, createUserAttachment } from './test_utils';
import { CommentType } from '../../../common';
import { createSOFindResponse } from '../test_utils';
describe('CasesService', () => {
describe('AttachmentService', () => {
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const mockLogger = loggerMock.create();
const persistableStateAttachmentTypeRegistry = createPersistableStateAttachmentTypeRegistryMock();
@ -35,6 +40,119 @@ describe('CasesService', () => {
});
});
describe('create', () => {
describe('Decoding', () => {
it('does not throw when the response has the required fields', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValue(createUserAttachment());
await expect(
service.create({
attributes: createUserAttachment().attributes,
references: [],
id: '1',
})
).resolves.not.toThrow();
});
it('strips excess fields', async () => {
unsecuredSavedObjectsClient.create.mockResolvedValue(createUserAttachment({ foo: 'bar' }));
const res = await service.create({
attributes: createUserAttachment().attributes,
references: [],
id: '1',
});
expect(res).toStrictEqual(createUserAttachment());
});
it('throws when the response is missing the attributes.comment', async () => {
const invalidAttachment = createUserAttachment();
unset(invalidAttachment, 'attributes.comment');
unsecuredSavedObjectsClient.create.mockResolvedValue(invalidAttachment);
await expect(
service.create({
attributes: createUserAttachment().attributes,
references: [],
id: '1',
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"comment\\",Invalid value \\"user\\" supplied to \\"type\\",Invalid value \\"undefined\\" supplied to \\"alertId\\",Invalid value \\"undefined\\" supplied to \\"index\\",Invalid value \\"undefined\\" supplied to \\"rule\\",Invalid value \\"undefined\\" supplied to \\"actions\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceMetadata\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceStorage\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentState\\""`
);
});
});
});
describe('bulkCreate', () => {
describe('Decoding', () => {
it('does not throw when the response has the required fields', async () => {
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [createUserAttachment()],
});
await expect(
service.bulkCreate({
attachments: [
{ attributes: createUserAttachment().attributes, references: [], id: '1' },
],
})
).resolves.not.toThrow();
});
it('returns error objects unmodified', async () => {
const userAttachment = createUserAttachment({ foo: 'bar' });
const errorResponseObj = createErrorSO();
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [errorResponseObj, userAttachment],
});
const res = await service.bulkCreate({
attachments: [
{ attributes: createUserAttachment().attributes, references: [], id: '1' },
{ attributes: createUserAttachment().attributes, references: [], id: '1' },
],
});
expect(res).toStrictEqual({ saved_objects: [errorResponseObj, createUserAttachment()] });
});
it('strips excess fields', async () => {
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [createUserAttachment({ foo: 'bar' })],
});
const res = await service.bulkCreate({
attachments: [{ attributes: createUserAttachment().attributes, references: [], id: '1' }],
});
expect(res).toStrictEqual({ saved_objects: [createUserAttachment()] });
});
it('throws when the response is missing the attributes.comment field', async () => {
const invalidAttachment = createUserAttachment();
unset(invalidAttachment, 'attributes.comment');
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [invalidAttachment],
});
await expect(
service.bulkCreate({
attachments: [
{ attributes: createUserAttachment().attributes, references: [], id: '1' },
],
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"comment\\",Invalid value \\"user\\" supplied to \\"type\\",Invalid value \\"undefined\\" supplied to \\"alertId\\",Invalid value \\"undefined\\" supplied to \\"index\\",Invalid value \\"undefined\\" supplied to \\"rule\\",Invalid value \\"undefined\\" supplied to \\"actions\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceMetadata\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceStorage\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentState\\""`
);
});
});
});
describe('update', () => {
const soClientRes = {
id: '1',
@ -85,6 +203,46 @@ describe('CasesService', () => {
expect(res).toEqual({ ...soClientRes, attributes: externalReferenceAttachmentESAttributes });
});
describe('Decoding', () => {
it('does not throw when the response has the required fields', async () => {
unsecuredSavedObjectsClient.update.mockResolvedValue(createUserAttachment());
await expect(
service.update({
updatedAttributes: { comment: 'yes', type: CommentType.user, owner: 'hi' },
attachmentId: '1',
})
).resolves.not.toThrow();
});
it('strips excess fields', async () => {
unsecuredSavedObjectsClient.update.mockResolvedValue(createUserAttachment({ foo: 'bar' }));
const res = await service.update({
updatedAttributes: { comment: 'yes', type: CommentType.user, owner: 'hi' },
attachmentId: '1',
});
expect(res).toStrictEqual(createUserAttachment());
});
it('throws when the response is missing the attributes.rule.name', async () => {
const invalidAttachment = createAlertAttachment();
unset(invalidAttachment, 'attributes.rule.name');
unsecuredSavedObjectsClient.update.mockResolvedValue(invalidAttachment);
await expect(
service.update({
updatedAttributes: createUserAttachment().attributes,
attachmentId: '1',
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"alert\\" supplied to \\"type\\",Invalid value \\"undefined\\" supplied to \\"rule,name\\""`
);
});
});
});
describe('bulkUpdate', () => {
@ -139,5 +297,104 @@ describe('CasesService', () => {
],
});
});
describe('Decoding', () => {
it('does not throw when the response has the required fields', async () => {
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [createUserAttachment()],
});
const updatedAttributes = createUserAttachment().attributes;
await expect(
service.bulkUpdate({ comments: [{ attachmentId: '1', updatedAttributes }] })
).resolves.not.toThrow();
});
it('returns error objects unmodified', async () => {
const userAttachment = createUserAttachment({ foo: 'bar' });
const errorResponseObj = createErrorSO();
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [errorResponseObj, userAttachment],
});
const res = await service.bulkUpdate({
comments: [
{ attachmentId: '1', updatedAttributes: userAttachment.attributes },
{ attachmentId: '1', updatedAttributes: userAttachment.attributes },
],
});
expect(res).toStrictEqual({ saved_objects: [errorResponseObj, createUserAttachment()] });
});
it('strips excess fields', async () => {
const updatedAttributes = createUserAttachment().attributes;
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [createUserAttachment({ foo: 'bar' })],
});
const res = await service.bulkUpdate({
comments: [{ attachmentId: '1', updatedAttributes }],
});
expect(res).toStrictEqual({ saved_objects: [createUserAttachment()] });
});
it('throws when the response is missing the attributes.rule.name field', async () => {
const invalidAttachment = createAlertAttachment();
unset(invalidAttachment, 'attributes.rule.name');
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [invalidAttachment],
});
const updatedAttributes = createAlertAttachment().attributes;
await expect(
service.bulkUpdate({ comments: [{ attachmentId: '1', updatedAttributes }] })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"alert\\" supplied to \\"type\\",Invalid value \\"undefined\\" supplied to \\"rule,name\\""`
);
});
});
});
describe('find', () => {
describe('Decoding', () => {
it('does not throw when the response has the required fields', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValue(
createSOFindResponse([{ ...createUserAttachment(), score: 0 }])
);
await expect(service.find({})).resolves.not.toThrow();
});
it('strips excess fields', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValue(
createSOFindResponse([{ ...createUserAttachment({ foo: 'bar' }), score: 0 }])
);
const res = await service.find({});
expect(res).toStrictEqual(createSOFindResponse([{ ...createUserAttachment(), score: 0 }]));
});
it('throws when the response is missing the attributes.rule.name field', async () => {
const invalidAttachment = createUserAttachment();
unset(invalidAttachment, 'attributes.comment');
unsecuredSavedObjectsClient.find.mockResolvedValue(
createSOFindResponse([{ ...invalidAttachment, score: 0 }])
);
await expect(service.find({})).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"comment\\",Invalid value \\"user\\" supplied to \\"type\\",Invalid value \\"undefined\\" supplied to \\"alertId\\",Invalid value \\"undefined\\" supplied to \\"index\\",Invalid value \\"undefined\\" supplied to \\"rule\\",Invalid value \\"undefined\\" supplied to \\"actions\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceMetadata\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceStorage\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentState\\""`
);
});
});
});
});

View file

@ -9,14 +9,15 @@ import type {
SavedObjectsBulkResponse,
SavedObjectsBulkUpdateResponse,
SavedObjectsFindResponse,
SavedObjectsFindResult,
SavedObjectsUpdateResponse,
} from '@kbn/core/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { CommentType } from '../../../common/api';
import { CommentType, decodeOrThrow } from '../../../common/api';
import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../common/constants';
import { buildFilter, combineFilters } from '../../client/utils';
import { defaultSortField } from '../../common/utils';
import { defaultSortField, isSOError } from '../../common/utils';
import type { AggregationResponse } from '../../client/metrics/types';
import {
extractAttachmentSORefsFromAttributes,
@ -34,6 +35,7 @@ import type {
DeleteAttachmentArgs,
ServiceContext,
UpdateAttachmentArgs,
UpdateArgs,
} from './types';
import { AttachmentGetter } from './operations/get';
import type {
@ -41,6 +43,10 @@ import type {
AttachmentTransformedAttributes,
AttachmentSavedObjectTransformed,
} from '../../common/types/attachments';
import {
AttachmentTransformedAttributesRt,
AttachmentPartialAttributesRt,
} from '../../common/types/attachments';
export class AttachmentService {
private readonly _getter: AttachmentGetter;
@ -140,7 +146,7 @@ export class AttachmentService {
}
this.context.log.debug(`Attempting to DELETE attachments ${attachmentIds}`);
return await this.context.unsecuredSavedObjectsClient.bulkDelete(
await this.context.unsecuredSavedObjectsClient.bulkDelete(
attachmentIds.map((id) => ({ id, type: CASE_COMMENT_SAVED_OBJECT })),
{
refresh,
@ -179,10 +185,16 @@ export class AttachmentService {
}
);
return injectAttachmentSOAttributesFromRefs(
const transformedAttachment = injectAttachmentSOAttributesFromRefs(
attachment,
this.context.persistableStateAttachmentTypeRegistry
);
const validatedAttributes = decodeOrThrow(AttachmentTransformedAttributesRt)(
transformedAttachment.attributes
);
return Object.assign(transformedAttachment, { attributes: validatedAttributes });
} catch (error) {
this.context.log.error(`Error on POST a new comment: ${error}`);
throw error;
@ -215,20 +227,38 @@ export class AttachmentService {
{ refresh }
);
return {
saved_objects: res.saved_objects.map((so) => {
return injectAttachmentSOAttributesFromRefs(
so,
this.context.persistableStateAttachmentTypeRegistry
);
}),
};
return this.transformAndDecodeBulkCreateResponse(res);
} catch (error) {
this.context.log.error(`Error on bulk create attachments: ${error}`);
throw error;
}
}
private transformAndDecodeBulkCreateResponse(
res: SavedObjectsBulkResponse<AttachmentPersistedAttributes>
): SavedObjectsBulkResponse<AttachmentTransformedAttributes> {
const validatedAttachments: AttachmentSavedObjectTransformed[] = [];
for (const so of res.saved_objects) {
if (isSOError(so)) {
validatedAttachments.push(so as AttachmentSavedObjectTransformed);
} else {
const transformedAttachment = injectAttachmentSOAttributesFromRefs(
so,
this.context.persistableStateAttachmentTypeRegistry
);
const validatedAttributes = decodeOrThrow(AttachmentTransformedAttributesRt)(
transformedAttachment.attributes
);
validatedAttachments.push(Object.assign(so, { attributes: validatedAttributes }));
}
}
return Object.assign(res, { saved_objects: validatedAttachments });
}
public async update({
attachmentId,
updatedAttributes,
@ -266,11 +296,17 @@ export class AttachmentService {
}
);
return injectAttachmentSOAttributesFromRefsForPatch(
const transformedAttachment = injectAttachmentSOAttributesFromRefsForPatch(
updatedAttributes,
res,
this.context.persistableStateAttachmentTypeRegistry
);
const validatedAttributes = decodeOrThrow(AttachmentPartialAttributesRt)(
transformedAttachment.attributes
);
return Object.assign(transformedAttachment, { attributes: validatedAttributes });
} catch (error) {
this.context.log.error(`Error on UPDATE comment ${attachmentId}: ${error}`);
throw error;
@ -319,15 +355,7 @@ export class AttachmentService {
{ refresh }
);
return {
saved_objects: res.saved_objects.map((so, index) => {
return injectAttachmentSOAttributesFromRefsForPatch(
comments[index].updatedAttributes,
so,
this.context.persistableStateAttachmentTypeRegistry
);
}),
};
return this.transformAndDecodeBulkUpdateResponse(res, comments);
} catch (error) {
this.context.log.error(
`Error on UPDATE comments ${comments.map((c) => c.attachmentId).join(', ')}: ${error}`
@ -336,6 +364,41 @@ export class AttachmentService {
}
}
private transformAndDecodeBulkUpdateResponse(
res: SavedObjectsBulkUpdateResponse<AttachmentPersistedAttributes>,
comments: UpdateArgs[]
): SavedObjectsBulkUpdateResponse<AttachmentTransformedAttributes> {
const validatedAttachments: Array<SavedObjectsUpdateResponse<AttachmentTransformedAttributes>> =
[];
for (let i = 0; i < res.saved_objects.length; i++) {
const attachment = res.saved_objects[i];
if (isSOError(attachment)) {
// Forcing the type here even though it is an error. The client is responsible for
// determining what to do with the errors
// TODO: we should fix the return type of this function so that it can return errors
validatedAttachments.push(attachment as AttachmentSavedObjectTransformed);
} else {
const transformedAttachment = injectAttachmentSOAttributesFromRefsForPatch(
comments[i].updatedAttributes,
attachment,
this.context.persistableStateAttachmentTypeRegistry
);
const validatedAttributes = decodeOrThrow(AttachmentPartialAttributesRt)(
transformedAttachment.attributes
);
validatedAttachments.push(
Object.assign(transformedAttachment, { attributes: validatedAttributes })
);
}
}
return Object.assign(res, { saved_objects: validatedAttachments });
}
public async find({
options,
}: {
@ -350,20 +413,29 @@ export class AttachmentService {
type: CASE_COMMENT_SAVED_OBJECT,
});
return {
...res,
saved_objects: res.saved_objects.map((so) => {
const injectedSO = injectAttachmentSOAttributesFromRefs(
so,
this.context.persistableStateAttachmentTypeRegistry
);
const validatedAttachments: Array<SavedObjectsFindResult<AttachmentTransformedAttributes>> =
[];
return {
...so,
...injectedSO,
};
}),
};
for (const so of res.saved_objects) {
const transformedAttachment = injectAttachmentSOAttributesFromRefs(
so,
this.context.persistableStateAttachmentTypeRegistry
// casting here because injectAttachmentSOAttributesFromRefs returns a SavedObject but we need a SavedObjectsFindResult
// which has the score in it. The score is returned but the type is not correct
) as SavedObjectsFindResult<AttachmentTransformedAttributes>;
const validatedAttributes = decodeOrThrow(AttachmentTransformedAttributesRt)(
transformedAttachment.attributes
);
validatedAttachments.push(
Object.assign(transformedAttachment, {
attributes: validatedAttributes,
})
);
}
return Object.assign(res, { saved_objects: validatedAttachments });
} catch (error) {
this.context.log.error(`Error on find comments: ${error}`);
throw error;

View file

@ -5,15 +5,28 @@
* 2.0.
*/
import { unset } from 'lodash';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import type { SavedObjectsFindResponse } from '@kbn/core/server';
import { loggerMock } from '@kbn/logging-mocks';
import { createPersistableStateAttachmentTypeRegistryMock } from '../../../attachment_framework/mocks';
import { AttachmentGetter } from './get';
import {
createAlertAttachment,
createErrorSO,
createFileAttachment,
createUserAttachment,
} from '../test_utils';
import { mockPointInTimeFinder, createSOFindResponse } from '../../test_utils';
describe('AttachmentService getter', () => {
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const mockLogger = loggerMock.create();
const persistableStateAttachmentTypeRegistry = createPersistableStateAttachmentTypeRegistryMock();
const mockFinder = (soFindRes: SavedObjectsFindResponse) =>
mockPointInTimeFinder(unsecuredSavedObjectsClient)(soFindRes);
let attachmentGetter: AttachmentGetter;
beforeEach(async () => {
@ -25,6 +38,163 @@ describe('AttachmentService getter', () => {
});
});
describe('bulkGet', () => {
describe('Decoding', () => {
it('does not throw when the response has the required fields', async () => {
unsecuredSavedObjectsClient.bulkGet.mockResolvedValue({
saved_objects: [createUserAttachment()],
});
await expect(attachmentGetter.bulkGet(['1'])).resolves.not.toThrow();
});
it('does not modified the error saved objects', async () => {
unsecuredSavedObjectsClient.bulkGet.mockResolvedValue({
saved_objects: [createUserAttachment(), createErrorSO()],
});
const res = await attachmentGetter.bulkGet(['1', '2']);
expect(res).toStrictEqual({ saved_objects: [createUserAttachment(), createErrorSO()] });
});
it('strips excess fields', async () => {
unsecuredSavedObjectsClient.bulkGet.mockResolvedValue({
saved_objects: [{ ...createUserAttachment({ foo: 'bar' }) }],
});
const res = await attachmentGetter.bulkGet(['1']);
expect(res).toStrictEqual({ saved_objects: [createUserAttachment()] });
});
it('throws when the response is missing the attributes.comment field', async () => {
const invalidAttachment = createUserAttachment();
unset(invalidAttachment, 'attributes.comment');
unsecuredSavedObjectsClient.bulkGet.mockResolvedValue({
saved_objects: [invalidAttachment],
});
await expect(attachmentGetter.bulkGet(['1'])).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"comment\\",Invalid value \\"user\\" supplied to \\"type\\",Invalid value \\"undefined\\" supplied to \\"alertId\\",Invalid value \\"undefined\\" supplied to \\"index\\",Invalid value \\"undefined\\" supplied to \\"rule\\",Invalid value \\"undefined\\" supplied to \\"actions\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceMetadata\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceStorage\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentState\\""`
);
});
});
});
describe('getAllAlertsAttachToCase', () => {
describe('Decoding', () => {
it('does not throw when the response has the required fields', async () => {
const soFindRes = createSOFindResponse([{ ...createAlertAttachment(), score: 0 }]);
mockFinder(soFindRes);
await expect(
attachmentGetter.getAllAlertsAttachToCase({ caseId: '1' })
).resolves.not.toThrow();
});
it('strips excess fields', async () => {
const soFindRes = createSOFindResponse([
{ ...createAlertAttachment({ foo: 'bar' }), score: 0 },
]);
mockFinder(soFindRes);
const res = await attachmentGetter.getAllAlertsAttachToCase({ caseId: '1' });
expect(res).toStrictEqual([{ ...createAlertAttachment(), score: 0 }]);
});
it('throws when the response is missing the attributes.alertId field', async () => {
const invalidAlert = { ...createAlertAttachment(), score: 0 };
unset(invalidAlert, 'attributes.alertId');
const soFindRes = createSOFindResponse([invalidAlert]);
mockFinder(soFindRes);
await expect(
attachmentGetter.getAllAlertsAttachToCase({ caseId: '1' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"alertId\\""`
);
});
});
});
describe('get', () => {
describe('Decoding', () => {
it('does not throw when the response has the required fields', async () => {
unsecuredSavedObjectsClient.get.mockResolvedValue(createUserAttachment());
await expect(attachmentGetter.get({ attachmentId: '1' })).resolves.not.toThrow();
});
it('strips excess fields', async () => {
unsecuredSavedObjectsClient.get.mockResolvedValue({
...createUserAttachment({ foo: 'bar' }),
});
const res = await attachmentGetter.get({ attachmentId: '1' });
expect(res).toStrictEqual(createUserAttachment());
});
it('throws when the response is missing the attributes.comment field', async () => {
const invalidAttachment = createUserAttachment();
unset(invalidAttachment, 'attributes.comment');
unsecuredSavedObjectsClient.get.mockResolvedValue(invalidAttachment);
await expect(
attachmentGetter.get({ attachmentId: '1' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"comment\\",Invalid value \\"user\\" supplied to \\"type\\",Invalid value \\"undefined\\" supplied to \\"alertId\\",Invalid value \\"undefined\\" supplied to \\"index\\",Invalid value \\"undefined\\" supplied to \\"rule\\",Invalid value \\"undefined\\" supplied to \\"actions\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceMetadata\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceId\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceStorage\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentState\\""`
);
});
});
});
describe('getFileAttachments', () => {
describe('Decoding', () => {
it('does not throw when the response has the required fields', async () => {
const soFindRes = createSOFindResponse([{ ...createFileAttachment(), score: 0 }]);
mockFinder(soFindRes);
await expect(
attachmentGetter.getFileAttachments({ caseId: '1', fileIds: ['1'] })
).resolves.not.toThrow();
});
it('strips excess fields', async () => {
const soFindRes = createSOFindResponse([
{ ...createFileAttachment({ foo: 'bar' }), score: 0 },
]);
mockFinder(soFindRes);
const res = await attachmentGetter.getFileAttachments({ caseId: 'caseId', fileIds: ['1'] });
expect(res).toStrictEqual([
{ ...createFileAttachment({ externalReferenceId: 'my-id' }), score: 0 },
]);
});
it('throws when the response is missing the attributes.externalReferenceAttachmentTypeId field', async () => {
const invalidFile = { ...createFileAttachment(), score: 0 };
unset(invalidFile, 'attributes.externalReferenceAttachmentTypeId');
const soFindRes = createSOFindResponse([invalidFile]);
mockFinder(soFindRes);
await expect(
attachmentGetter.getFileAttachments({ caseId: '1', fileIds: ['1'] })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"comment\\",Invalid value \\"externalReference\\" supplied to \\"type\\",Invalid value \\"undefined\\" supplied to \\"alertId\\",Invalid value \\"undefined\\" supplied to \\"index\\",Invalid value \\"undefined\\" supplied to \\"rule\\",Invalid value \\"undefined\\" supplied to \\"actions\\",Invalid value \\"undefined\\" supplied to \\"externalReferenceAttachmentTypeId\\",Invalid value \\"savedObject\\" supplied to \\"externalReferenceStorage,type\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentTypeId\\",Invalid value \\"undefined\\" supplied to \\"persistableStateAttachmentState\\""`
);
});
});
});
describe('getAllAlertIds', () => {
const aggsRes = {
aggregations: { alertIds: { buckets: [{ key: 'alert-id-1' }, { key: 'alert-id-2' }] } },

View file

@ -5,7 +5,11 @@
* 2.0.
*/
import type { SavedObject } from '@kbn/core/server';
import type {
SavedObject,
SavedObjectsBulkResponse,
SavedObjectsFindResponse,
} from '@kbn/core/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { FILE_SO_TYPE } from '@kbn/files-plugin/common';
import type {
@ -13,6 +17,7 @@ import type {
AttachmentTransformedAttributes,
AttachmentSavedObjectTransformed,
} from '../../../common/types/attachments';
import { AttachmentTransformedAttributesRt } from '../../../common/types/attachments';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
@ -21,7 +26,7 @@ import {
} from '../../../../common/constants';
import { buildFilter, combineFilters } from '../../../client/utils';
import type { AttachmentTotals, AttributesTypeAlerts } from '../../../../common/api';
import { CommentType } from '../../../../common/api';
import { CommentType, decodeOrThrow, AttributesTypeAlertsRt } from '../../../../common/api';
import type {
AlertIdsAggsResult,
BulkOptionalAttributes,
@ -36,6 +41,7 @@ import {
import { partitionByCaseAssociation } from '../../../common/partitioning';
import type { AttachmentSavedObject } from '../../../common/types';
import { getCaseReferenceId } from '../../../common/references';
import { isSOError } from '../../../common/utils';
export class AttachmentGetter {
constructor(private readonly context: ServiceContext) {}
@ -53,14 +59,7 @@ export class AttachmentGetter {
attachmentIds.map((id) => ({ id, type: CASE_COMMENT_SAVED_OBJECT }))
);
return {
saved_objects: response.saved_objects.map((so) =>
injectAttachmentAttributesAndHandleErrors(
so,
this.context.persistableStateAttachmentTypeRegistry
)
),
};
return this.transformAndDecodeBulkGetResponse(response);
} catch (error) {
this.context.log.error(
`Error retrieving attachments with ids ${attachmentIds.join()}: ${error}`
@ -69,6 +68,35 @@ export class AttachmentGetter {
}
}
private transformAndDecodeBulkGetResponse(
response: SavedObjectsBulkResponse<AttachmentPersistedAttributes>
): BulkOptionalAttributes<AttachmentTransformedAttributes> {
const validatedAttachments: AttachmentSavedObjectTransformed[] = [];
for (const so of response.saved_objects) {
if (isSOError(so)) {
// Forcing the type here even though it is an error. The caller is responsible for
// determining what to do with the errors
// TODO: we should fix the return type of this bulkGet so that it can return errors
validatedAttachments.push(so as AttachmentSavedObjectTransformed);
} else {
const transformedAttachment = injectAttachmentAttributesAndHandleErrors(
so,
this.context.persistableStateAttachmentTypeRegistry
);
const validatedAttributes = decodeOrThrow(AttachmentTransformedAttributesRt)(
transformedAttachment.attributes
);
validatedAttachments.push(
Object.assign(transformedAttachment, { attributes: validatedAttributes })
);
}
}
return Object.assign(response, { saved_objects: validatedAttachments });
}
public async getAttachmentIdsForCases({ caseIds }: { caseIds: string[] }) {
try {
this.context.log.debug(
@ -138,11 +166,7 @@ export class AttachmentGetter {
let result: Array<SavedObject<AttributesTypeAlerts>> = [];
for await (const userActionSavedObject of finder.find()) {
result = result.concat(
// We need a cast here because to limited attachment type conflicts with the expected result even though they
// should be the same
userActionSavedObject.saved_objects as unknown as Array<SavedObject<AttributesTypeAlerts>>
);
result = result.concat(AttachmentGetter.decodeAlerts(userActionSavedObject));
}
return result;
@ -152,6 +176,16 @@ export class AttachmentGetter {
}
}
private static decodeAlerts(
response: SavedObjectsFindResponse<AttachmentPersistedAttributes>
): Array<SavedObject<AttributesTypeAlerts>> {
return response.saved_objects.map((so) => {
const validatedAttributes = decodeOrThrow(AttributesTypeAlertsRt)(so.attributes);
return Object.assign(so, { attributes: validatedAttributes });
});
}
/**
* Retrieves all the alerts attached to a case.
*/
@ -198,10 +232,16 @@ export class AttachmentGetter {
attachmentId
);
return injectAttachmentSOAttributesFromRefs(
const transformedAttachment = injectAttachmentSOAttributesFromRefs(
res,
this.context.persistableStateAttachmentTypeRegistry
);
const validatedAttributes = decodeOrThrow(AttachmentTransformedAttributesRt)(
transformedAttachment.attributes
);
return Object.assign(transformedAttachment, { attributes: validatedAttributes });
} catch (error) {
this.context.log.error(`Error on GET attachment ${attachmentId}: ${error}`);
throw error;
@ -332,16 +372,7 @@ export class AttachmentGetter {
const foundAttachments: AttachmentSavedObjectTransformed[] = [];
for await (const attachmentSavedObjects of finder.find()) {
foundAttachments.push(
...attachmentSavedObjects.saved_objects.map((attachment) => {
const modifiedAttachment = injectAttachmentSOAttributesFromRefs(
attachment,
this.context.persistableStateAttachmentTypeRegistry
);
return modifiedAttachment;
})
);
foundAttachments.push(...this.transformAndDecodeFileAttachments(attachmentSavedObjects));
}
const [validFileAttachments, invalidFileAttachments] = partitionByCaseAssociation(
@ -358,6 +389,23 @@ export class AttachmentGetter {
}
}
private transformAndDecodeFileAttachments(
response: SavedObjectsFindResponse<AttachmentPersistedAttributes>
): AttachmentSavedObjectTransformed[] {
return response.saved_objects.map((so) => {
const transformedFileAttachment = injectAttachmentSOAttributesFromRefs(
so,
this.context.persistableStateAttachmentTypeRegistry
);
const validatedAttributes = decodeOrThrow(AttachmentTransformedAttributesRt)(
transformedFileAttachment.attributes
);
return Object.assign(transformedFileAttachment, { attributes: validatedAttributes });
});
}
private logInvalidFileAssociations(
attachments: AttachmentSavedObject[],
fileIds: string[],

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 type { SavedObject } from '@kbn/core/server';
import { FILE_SO_TYPE } from '@kbn/files-plugin/common';
import type {
AttributesTypeAlerts,
AttributesTypeUser,
CommentAttributesWithoutRefs,
ExternalReferenceWithoutRefs,
} from '../../../common/api';
import {
ExternalReferenceStorageType,
FILE_ATTACHMENT_TYPE,
CommentType,
} from '../../../common/api';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
SECURITY_SOLUTION_OWNER,
} from '../../../common/constants';
import { CASE_REF_NAME, EXTERNAL_REFERENCE_REF_NAME } from '../../common/constants';
export const createErrorSO = () =>
({
id: '1',
type: CASE_COMMENT_SAVED_OBJECT,
error: {
error: 'error',
message: 'message',
statusCode: 500,
},
references: [],
// casting because this complains about attributes not being there
} as unknown as SavedObject<AttributesTypeUser>);
export const createUserAttachment = (attributes?: object): SavedObject<AttributesTypeUser> => {
return {
id: '1',
type: CASE_COMMENT_SAVED_OBJECT,
attributes: {
comment: 'Wow, good luck catching that bad meanie!',
type: CommentType.user as const,
created_at: '2019-11-25T21:55:00.177Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
owner: SECURITY_SOLUTION_OWNER,
pushed_at: null,
pushed_by: null,
updated_at: '2019-11-25T21:55:00.177Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
...attributes,
},
references: [],
};
};
export const createAlertAttachment = (attributes?: object): SavedObject<AttributesTypeAlerts> => {
return {
id: '1',
type: CASE_COMMENT_SAVED_OBJECT,
attributes: {
alertId: 'alert1',
index: 'index',
rule: {
id: 'ruleid',
name: 'name',
},
type: CommentType.alert as const,
created_at: '2019-11-25T21:55:00.177Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
owner: SECURITY_SOLUTION_OWNER,
pushed_at: null,
pushed_by: null,
updated_at: '2019-11-25T21:55:00.177Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
...attributes,
},
references: [],
};
};
const fileMetadata = () => ({
name: 'test_file',
extension: 'png',
mimeType: 'image/png',
created: '2023-02-27T20:26:54.345Z',
});
const fileAttachmentMetadata = () => ({
files: [fileMetadata()],
});
const getFilesAttachmentReq = (): ExternalReferenceWithoutRefs => {
return {
type: CommentType.externalReference,
owner: 'securitySolutionFixture',
externalReferenceStorage: {
type: ExternalReferenceStorageType.savedObject as const,
soType: FILE_SO_TYPE,
},
externalReferenceAttachmentTypeId: FILE_ATTACHMENT_TYPE,
externalReferenceMetadata: { ...fileAttachmentMetadata },
};
};
export const createFileAttachment = (
attributes?: object
): SavedObject<CommentAttributesWithoutRefs> => {
return {
id: '1',
type: CASE_COMMENT_SAVED_OBJECT,
attributes: {
...getFilesAttachmentReq(),
created_at: '2019-11-25T21:55:00.177Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
owner: SECURITY_SOLUTION_OWNER,
pushed_at: null,
pushed_by: null,
updated_at: '2019-11-25T21:55:00.177Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
...attributes,
},
references: [
{ id: 'my-id', name: EXTERNAL_REFERENCE_REF_NAME, type: FILE_SO_TYPE },
{ id: 'caseId', name: CASE_REF_NAME, type: CASE_SAVED_OBJECT },
],
};
};

View file

@ -5,7 +5,13 @@
* 2.0.
*/
import type { SavedObject, SavedObjectReference, SavedObjectsFindResult } from '@kbn/core/server';
import type {
SavedObject,
SavedObjectReference,
SavedObjectsClientContract,
SavedObjectsFindResponse,
SavedObjectsFindResult,
} from '@kbn/core/server';
import { ACTION_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server';
import { CONNECTOR_ID_REFERENCE_NAME, PUSH_CONNECTOR_ID_REFERENCE_NAME } from '../common/constants';
import type {
@ -251,3 +257,17 @@ export const createSOFindResponse = <T>(savedObjects: Array<SavedObjectsFindResu
per_page: savedObjects.length,
page: 1,
});
export const mockPointInTimeFinder =
(unsecuredSavedObjectsClient: jest.Mocked<SavedObjectsClientContract>) =>
(soFindRes: SavedObjectsFindResponse) => {
unsecuredSavedObjectsClient.createPointInTimeFinder.mockReturnValue({
close: jest.fn(),
// @ts-expect-error
find: function* asyncGenerator() {
yield {
...soFindRes,
};
},
});
};

View file

@ -16,7 +16,7 @@ import {
createUserActionFindSO,
createUserActionSO,
} from '../test_utils';
import { createSOFindResponse } from '../../test_utils';
import { createSOFindResponse, mockPointInTimeFinder } from '../../test_utils';
import { omit } from 'lodash';
import type { SavedObjectsFindResponse } from '@kbn/core/server';
@ -46,23 +46,14 @@ describe('UserActionsService: Finder', () => {
unsecuredSavedObjectsClient.find.mockResolvedValue(soFindRes);
};
const mockPointInTimeFinder = (soFindRes: SavedObjectsFindResponse) => {
unsecuredSavedObjectsClient.createPointInTimeFinder.mockReturnValue({
close: jest.fn(),
// @ts-expect-error
find: function* asyncGenerator() {
yield {
...soFindRes,
};
},
});
};
const mockFinder = (soFindRes: SavedObjectsFindResponse) =>
mockPointInTimeFinder(unsecuredSavedObjectsClient)(soFindRes);
const decodingTests: Array<
[keyof UserActionFinder, (soFindRes: SavedObjectsFindResponse) => void]
> = [
['find', mockFind],
['findStatusChanges', mockPointInTimeFinder],
['findStatusChanges', mockFinder],
];
describe('find', () => {
@ -84,7 +75,7 @@ describe('UserActionsService: Finder', () => {
const userAction = createUserActionSO();
const attributes = omit({ ...userAction.attributes }, 'comment_id');
const soFindRes = createSOFindResponse([{ ...userAction, attributes, score: 0 }]);
mockPointInTimeFinder(soFindRes);
mockFinder(soFindRes);
const res = await finder.findStatusChanges({ caseId: '1' });
const commentId = res[0].attributes.comment_id;