[Cases] Refactor decode logic (#158515)

This PR refactors a couple things:

1. Moves the decodeOrThrow logic inside of the `try/catch` within the
client. This handles the scenario where another plugin is calling the
client. This way we wrap the error such that it is more consumable by
other plugins. This also makes the code more consistent.
2. Adds `decodeWithExcessOrThrow` to a couple places we didn't have it
in the client
3. Adds `decodeWithExcessOrThrow` to a few places in the HTTP route
handlers where we were access fields from the query params or body but
weren't validating that they existed
4. Ensured we had `decodeOrThrow` in the service layer before returning
to the client
5. Unskipped a few tests now that we have the `strict` io-ts types in
place
This commit is contained in:
Jonathan Buttner 2023-05-30 08:40:51 -04:00 committed by GitHub
parent 815fddb9f0
commit 6547d33635
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1041 additions and 234 deletions

View file

@ -28,7 +28,6 @@ import {
BulkCreateCommentRequestRt,
BulkGetAttachmentsRequestRt,
BulkGetAttachmentsResponseRt,
FindCommentsArgsRt,
} from '.';
describe('Comments', () => {
@ -750,35 +749,6 @@ describe('Comments', () => {
});
});
describe('FindCommentsArgsRt', () => {
const defaultRequest = {
caseID: 'basic-case-id',
queryParams: {
page: 1,
perPage: 10,
sortOrder: 'asc',
},
};
it('has expected attributes in request', () => {
const query = FindCommentsArgsRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it('removes foo:bar attributes from request', () => {
const query = FindCommentsArgsRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
});
describe('BulkCreateCommentRequestRt', () => {
const defaultRequest = [
{

View file

@ -298,13 +298,6 @@ export const FindCommentsQueryParamsRt = rt.exact(
})
);
export const FindCommentsArgsRt = rt.intersection([
rt.strict({
caseID: rt.string,
}),
rt.strict({ queryParams: rt.union([FindCommentsQueryParamsRt, rt.undefined]) }),
]);
export const BulkCreateCommentRequestRt = rt.array(CommentRequestRt);
export const BulkGetAttachmentsRequestRt = rt.strict({

View file

@ -61,7 +61,7 @@ const CaseUserActionBasicWithoutConnectorIdRt = rt.intersection([
UserActionCommonAttributesRt,
]);
const CaseUserActionDeprecatedResponseRt = rt.intersection([
export const CaseUserActionDeprecatedResponseRt = rt.intersection([
CaseUserActionBasicRt,
CaseUserActionInjectedDeprecatedIdsRt,
]);

View file

@ -15,7 +15,6 @@ import {
describe('Metrics case', () => {
describe('SingleCaseMetricsRequestRt', () => {
const defaultRequest = {
caseId: 'basic-case-id',
features: ['alerts.count', 'lifespan'],
};

View file

@ -73,10 +73,6 @@ const AlertUsersMetricsRt = rt.strict({
});
export const SingleCaseMetricsRequestRt = rt.strict({
/**
* The ID of the case.
*/
caseId: rt.string,
/**
* The metrics to retrieve.
*/

View file

@ -27,8 +27,6 @@ import { validateRegisteredAttachments } from './validators';
export const addComment = async (addArgs: AddArgs, clientArgs: CasesClientArgs): Promise<Case> => {
const { comment, caseId } = addArgs;
const query = decodeWithExcessOrThrow(CommentRequestRt)(comment);
const {
logger,
authorization,
@ -36,8 +34,11 @@ export const addComment = async (addArgs: AddArgs, clientArgs: CasesClientArgs):
externalReferenceAttachmentTypeRegistry,
} = clientArgs;
decodeCommentRequest(comment, externalReferenceAttachmentTypeRegistry);
try {
const query = decodeWithExcessOrThrow(CommentRequestRt)(comment);
decodeCommentRequest(comment, externalReferenceAttachmentTypeRegistry);
const savedObjectID = SavedObjectsUtils.generateId();
await authorization.ensureAuthorized({

View file

@ -20,19 +20,12 @@ import { Operations } from '../../authorization';
import type { BulkCreateArgs } from './types';
import { validateRegisteredAttachments } from './validators';
/**
* Bulk create attachments to a case.
*
* @ignore
*/
export const bulkCreate = async (
args: BulkCreateArgs,
clientArgs: CasesClientArgs
): Promise<Case> => {
const { attachments, caseId } = args;
decodeWithExcessOrThrow(BulkCreateCommentRequestRt)(attachments);
const {
logger,
authorization,
@ -40,16 +33,18 @@ export const bulkCreate = async (
persistableStateAttachmentTypeRegistry,
} = clientArgs;
attachments.forEach((attachment) => {
decodeCommentRequest(attachment, externalReferenceAttachmentTypeRegistry);
validateRegisteredAttachments({
query: attachment,
persistableStateAttachmentTypeRegistry,
externalReferenceAttachmentTypeRegistry,
});
});
try {
decodeWithExcessOrThrow(BulkCreateCommentRequestRt)(attachments);
attachments.forEach((attachment) => {
decodeCommentRequest(attachment, externalReferenceAttachmentTypeRegistry);
validateRegisteredAttachments({
query: attachment,
persistableStateAttachmentTypeRegistry,
externalReferenceAttachmentTypeRegistry,
});
});
const [attachmentsWithIds, entities]: [Array<{ id: string } & CommentRequest>, OwnerEntity[]] =
attachments.reduce<[Array<{ id: string } & CommentRequest>, OwnerEntity[]]>(
([a, e], attachment) => {

View file

@ -18,9 +18,9 @@ describe('get', () => {
it('Invalid total items results in error', async () => {
await expect(() =>
findComment({ caseID: 'mock-id', queryParams: { page: 2, perPage: 9001 } }, clientArgs)
).rejects.toThrow(
'The number of documents is too high. Paginating through more than 10,000 documents is not possible.'
findComment({ caseID: 'mock-id', findQueryParams: { page: 2, perPage: 9001 } }, clientArgs)
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Failed to find comments case id: mock-id: Error: The number of documents is too high. Paginating through more than 10,000 documents is not possible."`
);
});
@ -28,10 +28,12 @@ describe('get', () => {
await expect(
findComment(
// @ts-expect-error: excess attribute
{ caseID: 'mock-id', queryParams: { page: 2, perPage: 9001 }, foo: 'bar' },
{ caseID: 'mock-id', findQueryParams: { page: 2, perPage: 9001, foo: 'bar' } },
clientArgs
)
).rejects.toThrow('invalid keys "foo"');
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Failed to find comments case id: mock-id: Error: invalid keys \\"foo\\""`
);
});
});
});

View file

@ -20,7 +20,7 @@ import type { FindCommentsArgs, GetAllAlertsAttachToCase, GetAllArgs, GetArgs }
import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../common/constants';
import {
FindCommentsArgsRt,
FindCommentsQueryParamsRt,
CommentType,
CommentsRt,
CommentRt,
@ -112,7 +112,7 @@ export const getAllAlertsAttachToCase = async (
* Retrieves the attachments for a case entity. This support pagination.
*/
export async function find(
data: FindCommentsArgs,
{ caseID, findQueryParams }: FindCommentsArgs,
clientArgs: CasesClientArgs
): Promise<CommentsFindResponse> {
const {
@ -121,11 +121,11 @@ export async function find(
authorization,
} = clientArgs;
const { caseID, queryParams } = decodeWithExcessOrThrow(FindCommentsArgsRt)(data);
validateFindCommentsPagination(queryParams);
try {
const queryParams = decodeWithExcessOrThrow(FindCommentsQueryParamsRt)(findQueryParams);
validateFindCommentsPagination(queryParams);
const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } =
await authorization.getAuthorizationFilter(Operations.findComments);

View file

@ -80,7 +80,7 @@ export interface FindCommentsArgs {
/**
* Optional parameters for filtering the returned attachments
*/
queryParams?: FindCommentsQueryParams;
findQueryParams?: FindCommentsQueryParams;
}
/**

View file

@ -11,6 +11,7 @@ import { CaseCommentModel } from '../../common/models';
import { createCaseError } from '../../common/error';
import { isCommentRequestTypeExternalReference } from '../../../common/utils/attachments';
import type { Case } from '../../../common/api';
import { CommentPatchRequestRt, decodeWithExcessOrThrow } from '../../../common/api';
import { CASE_SAVED_OBJECT } from '../../../common/constants';
import type { CasesClientArgs } from '..';
import { decodeCommentRequest } from '../utils';
@ -38,7 +39,7 @@ export async function update(
id: queryCommentId,
version: queryCommentVersion,
...queryRestAttributes
} = queryParams;
} = decodeWithExcessOrThrow(CommentPatchRequestRt)(queryParams);
decodeCommentRequest(queryRestAttributes, externalReferenceAttachmentTypeRegistry);

View file

@ -90,7 +90,9 @@ describe('create', () => {
await expect(
// @ts-expect-error foo is an invalid field
create({ ...theCase, foo: 'bar' }, clientArgs)
).rejects.toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"foo\\""`);
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Failed to create case: Error: invalid keys \\"foo\\""`
);
});
});
});

View file

@ -40,19 +40,19 @@ export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs)
authorization: auth,
} = clientArgs;
const query = decodeWithExcessOrThrow(CasePostRequestRt)(data);
if (query.title.length > MAX_TITLE_LENGTH) {
throw Boom.badRequest(
`The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}.`
);
}
if (query.tags.some(isInvalidTag)) {
throw Boom.badRequest('A tag must contain at least one non-space character');
}
try {
const query = decodeWithExcessOrThrow(CasePostRequestRt)(data);
if (query.title.length > MAX_TITLE_LENGTH) {
throw Boom.badRequest(
`The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}.`
);
}
if (query.tags.some(isInvalidTag)) {
throw Boom.badRequest('A tag must contain at least one non-space character');
}
const savedObjectID = SavedObjectsUtils.generateId();
await auth.ensureAuthorized({

View file

@ -33,6 +33,7 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P
authorization,
fileService,
} = clientArgs;
try {
const cases = await caseService.getCases({ caseIds: ids });
const entities = new Map<string, OwnerEntity>();

View file

@ -266,7 +266,9 @@ describe('update', () => {
},
clientArgs
)
).rejects.toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"foo\\""`);
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: invalid keys \\"foo\\""`
);
});
});
});

View file

@ -314,9 +314,9 @@ export const update = async (
authorization,
} = clientArgs;
const query = decodeWithExcessOrThrow(CasesPatchRequestRt)(cases);
try {
const query = decodeWithExcessOrThrow(CasesPatchRequestRt)(cases);
const myCases = await caseService.getCases({
caseIds: query.cases.map((q) => q.id),
});

View file

@ -30,6 +30,7 @@ import {
ConfigurationRt,
FindActionConnectorResponseRt,
decodeWithExcessOrThrow,
ConfigurationRequestRt,
} from '../../../common/api';
import { MAX_CONCURRENT_SEARCHES } from '../../../common/constants';
import { createCaseError } from '../../common/error';
@ -138,6 +139,7 @@ export async function get(
logger,
authorization,
} = clientArgs;
try {
const queryParams = decodeWithExcessOrThrow(GetConfigurationFindRequestRt)(params);
@ -339,7 +341,7 @@ export async function update(
}
async function create(
configuration: ConfigurationRequest,
configRequest: ConfigurationRequest,
clientArgs: CasesClientArgs,
casesClientInternal: CasesClientInternal
): Promise<Configuration> {
@ -350,7 +352,11 @@ async function create(
user,
authorization,
} = clientArgs;
try {
const validatedConfigurationRequest =
decodeWithExcessOrThrow(ConfigurationRequestRt)(configRequest);
let error = null;
const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } =
@ -364,7 +370,7 @@ async function create(
);
const filter = combineAuthorizedAndOwnerFilter(
configuration.owner,
validatedConfigurationRequest.owner,
authorizationFilter,
Operations.createConfiguration.savedObjectType
);
@ -399,7 +405,7 @@ async function create(
await authorization.ensureAuthorized({
operation: Operations.createConfiguration,
entities: [{ owner: configuration.owner, id: savedObjectID }],
entities: [{ owner: validatedConfigurationRequest.owner, id: savedObjectID }],
});
const creationDate = new Date().toISOString();
@ -408,22 +414,22 @@ async function create(
try {
mappings = (
await casesClientInternal.configuration.createMappings({
connector: configuration.connector,
owner: configuration.owner,
connector: validatedConfigurationRequest.connector,
owner: validatedConfigurationRequest.owner,
refresh: false,
})
).mappings;
} catch (e) {
error = e.isBoom
? e.output.payload.message
: `Error creating mapping for ${configuration.connector.name}`;
: `Error creating mapping for ${validatedConfigurationRequest.connector.name}`;
}
const post = await caseConfigureService.post({
unsecuredSavedObjectsClient,
attributes: {
...configuration,
connector: configuration.connector,
...validatedConfigurationRequest,
connector: validatedConfigurationRequest.connector,
created_at: creationDate,
created_by: user,
updated_at: null,

View file

@ -10,7 +10,6 @@ import type {
CasesMetricsRequest,
CasesStatusRequest,
CasesStatusResponse,
SingleCaseMetricsRequest,
CasesMetricsResponse,
} from '../../../common/api';
import type { CasesClient } from '../client';
@ -19,12 +18,13 @@ import type { CasesClientArgs } from '../types';
import { getStatusTotalsByType } from './get_status_totals';
import { getCaseMetrics } from './get_case_metrics';
import { getCasesMetrics } from './get_cases_metrics';
import type { GetCaseMetricsParams } from './types';
/**
* API for interacting with the metrics.
*/
export interface MetricsSubClient {
getCaseMetrics(params: SingleCaseMetricsRequest): Promise<SingleCaseMetricsResponse>;
getCaseMetrics(params: GetCaseMetricsParams): Promise<SingleCaseMetricsResponse>;
getCasesMetrics(params: CasesMetricsRequest): Promise<CasesMetricsResponse>;
/**
* Retrieves the total number of open, closed, and in-progress cases.
@ -42,7 +42,7 @@ export const createMetricsSubClient = (
casesClient: CasesClient
): MetricsSubClient => {
const casesSubClient: MetricsSubClient = {
getCaseMetrics: (params: SingleCaseMetricsRequest) =>
getCaseMetrics: (params: GetCaseMetricsParams) =>
getCaseMetrics(params, casesClient, clientArgs),
getCasesMetrics: (params: CasesMetricsRequest) =>
getCasesMetrics(params, casesClient, clientArgs),

View file

@ -6,25 +6,36 @@
*/
import { merge } from 'lodash';
import type { SingleCaseMetricsRequest, SingleCaseMetricsResponse } from '../../../common/api';
import { SingleCaseMetricsResponseRt } from '../../../common/api';
import type { SingleCaseMetricsResponse } from '../../../common/api';
import {
SingleCaseMetricsResponseRt,
SingleCaseMetricsRequestRt,
decodeWithExcessOrThrow,
} from '../../../common/api';
import { Operations } from '../../authorization';
import { createCaseError } from '../../common/error';
import type { CasesClient } from '../client';
import type { CasesClientArgs } from '../types';
import type { GetCaseMetricsParams } from './types';
import { buildHandlers } from './utils';
import { decodeOrThrow } from '../../../common/api/runtime_types';
export const getCaseMetrics = async (
params: SingleCaseMetricsRequest,
{ caseId, features }: GetCaseMetricsParams,
casesClient: CasesClient,
clientArgs: CasesClientArgs
): Promise<SingleCaseMetricsResponse> => {
const { logger } = clientArgs;
try {
await checkAuthorization(params, clientArgs);
const handlers = buildHandlers(params, casesClient, clientArgs);
const queryParams = decodeWithExcessOrThrow(SingleCaseMetricsRequestRt)({ features });
await checkAuthorization(caseId, clientArgs);
const handlers = buildHandlers(
{ caseId, features: queryParams.features },
casesClient,
clientArgs
);
const computedMetrics = await Promise.all(
Array.from(handlers).map(async (handler) => {
@ -40,23 +51,20 @@ export const getCaseMetrics = async (
} catch (error) {
throw createCaseError({
logger,
message: `Failed to retrieve metrics within client for case id: ${params.caseId}: ${error}`,
message: `Failed to retrieve metrics within client for case id: ${caseId}: ${error}`,
error,
});
}
};
const checkAuthorization = async (
params: SingleCaseMetricsRequest,
clientArgs: CasesClientArgs
) => {
const checkAuthorization = async (caseId: string, clientArgs: CasesClientArgs) => {
const {
services: { caseService },
authorization,
} = clientArgs;
const caseInfo = await caseService.getCase({
id: params.caseId,
id: caseId,
});
await authorization.ensureAuthorized({

View file

@ -26,9 +26,9 @@ export const getCasesMetrics = async (
): Promise<CasesMetricsResponse> => {
const { logger } = clientArgs;
const queryParams = decodeWithExcessOrThrow(CasesMetricsRequestRt)(params);
try {
const queryParams = decodeWithExcessOrThrow(CasesMetricsRequestRt)(params);
const handlers = buildHandlers(queryParams, casesClient, clientArgs);
const computedMetrics = await Promise.all(

View file

@ -37,3 +37,8 @@ export interface AllCasesBaseHandlerCommonOptions extends BaseHandlerCommonOptio
to?: string;
owner?: string | string[];
}
export interface GetCaseMetricsParams {
caseId: string;
features: string[];
}

View file

@ -14,15 +14,15 @@ import { AlertDetails } from './alerts/details';
import { Actions } from './actions';
import { Connectors } from './connectors';
import { Lifespan } from './lifespan';
import type { MetricsHandler } from './types';
import type { GetCaseMetricsParams, MetricsHandler } from './types';
import { MTTR } from './all_cases/mttr';
const isSingleCaseMetrics = (
params: SingleCaseMetricsRequest | CasesMetricsRequest
): params is SingleCaseMetricsRequest => (params as SingleCaseMetricsRequest).caseId != null;
params: GetCaseMetricsParams | CasesMetricsRequest
): params is GetCaseMetricsParams => (params as GetCaseMetricsParams).caseId != null;
export const buildHandlers = (
params: SingleCaseMetricsRequest | CasesMetricsRequest,
params: GetCaseMetricsParams | CasesMetricsRequest,
casesClient: CasesClient,
clientArgs: CasesClientArgs
): Set<MetricsHandler<unknown>> => {

View file

@ -8,64 +8,93 @@
import { omit } from 'lodash';
import { CaseStatuses } from '@kbn/cases-components';
import { ConnectorTypes, CaseSeverity, SECURITY_SOLUTION_OWNER } from '../../../common';
import { CaseTransformedAttributesRt, getPartialCaseTransformedAttributesRt } from './case';
import {
CaseTransformedAttributesRt,
getPartialCaseTransformedAttributesRt,
OwnerRt,
} from './case';
import { decodeOrThrow } from '../../../common/api';
describe('getPartialCaseTransformedAttributesRt', () => {
const theCaseAttributes = {
closed_at: null,
closed_by: null,
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
severity: CaseSeverity.LOW,
duration: null,
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
settings: {
syncAlerts: true,
},
owner: SECURITY_SOLUTION_OWNER,
assignees: [],
};
const caseTransformedAttributesProps = CaseTransformedAttributesRt.types.reduce(
(acc, type) => ({ ...acc, ...type.type.props }),
{}
);
describe('case types', () => {
describe('getPartialCaseTransformedAttributesRt', () => {
const theCaseAttributes = {
closed_at: null,
closed_by: null,
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
severity: CaseSeverity.LOW,
duration: null,
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
settings: {
syncAlerts: true,
},
owner: SECURITY_SOLUTION_OWNER,
assignees: [],
};
const caseTransformedAttributesProps = CaseTransformedAttributesRt.types.reduce(
(acc, type) => ({ ...acc, ...type.type.props }),
{}
);
const type = getPartialCaseTransformedAttributesRt();
const type = getPartialCaseTransformedAttributesRt();
it.each(Object.keys(caseTransformedAttributesProps))('does not throw if %s is omitted', (key) => {
const theCase = omit(theCaseAttributes, key);
const decodedRes = type.decode(theCase);
it.each(Object.keys(caseTransformedAttributesProps))(
'does not throw if %s is omitted',
(key) => {
const theCase = omit(theCaseAttributes, key);
const decodedRes = type.decode(theCase);
expect(decodedRes._tag).toEqual('Right');
// @ts-expect-error: the check above ensures that right exists
expect(decodedRes.right).toEqual(theCase);
expect(decodedRes._tag).toEqual('Right');
// @ts-expect-error: the check above ensures that right exists
expect(decodedRes.right).toEqual(theCase);
}
);
it('removes excess properties', () => {
const decodedRes = type.decode({ description: 'test', 'not-exists': 'excess' });
expect(decodedRes._tag).toEqual('Right');
// @ts-expect-error: the check above ensures that right exists
expect(decodedRes.right).toEqual({ description: 'test' });
});
});
it('removes excess properties', () => {
const decodedRes = type.decode({ description: 'test', 'not-exists': 'excess' });
describe('OwnerRt', () => {
it('strips excess fields from the result', () => {
const res = decodeOrThrow(OwnerRt)({
owner: 'yes',
created_at: '123',
});
expect(decodedRes._tag).toEqual('Right');
// @ts-expect-error: the check above ensures that right exists
expect(decodedRes.right).toEqual({ description: 'test' });
expect(res).toStrictEqual({
owner: 'yes',
});
});
it('throws an error when owner is not present', () => {
expect(() => decodeOrThrow(OwnerRt)({})).toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"owner\\""`
);
});
});
});

View file

@ -7,7 +7,7 @@
import type { SavedObject } from '@kbn/core-saved-objects-server';
import type { Type } from 'io-ts';
import { exact, partial } from 'io-ts';
import { exact, partial, strict, string } from 'io-ts';
import type { CaseAttributes } from '../../../common/api';
import { CaseAttributesRt } from '../../../common/api';
import type { ConnectorPersisted } from './connectors';
@ -64,3 +64,5 @@ export const getPartialCaseTransformedAttributesRt = (): Type<Partial<CaseAttrib
export type CaseSavedObject = SavedObject<CasePersistedAttributes>;
export type CaseSavedObjectTransformed = SavedObject<CaseTransformedAttributes>;
export const OwnerRt = strict({ owner: string });

View file

@ -29,7 +29,7 @@ export const findCommentsRoute = createCasesRoute({
return response.ok({
body: await client.attachments.find({
caseID: request.params.case_id,
queryParams: query,
findQueryParams: query,
}),
});
} catch (error) {

View file

@ -11,7 +11,10 @@ import { INTERNAL_DELETE_FILE_ATTACHMENTS_URL } from '../../../../common/constan
import { createCasesRoute } from '../create_cases_route';
import { createCaseError } from '../../../common/error';
import { escapeHatch } from '../utils';
import type { BulkDeleteFileAttachmentsRequest } from '../../../../common/api';
import {
BulkDeleteFileAttachmentsRequestRt,
decodeWithExcessOrThrow,
} from '../../../../common/api';
export const bulkDeleteFileAttachments = createCasesRoute({
method: 'post',
@ -27,7 +30,7 @@ export const bulkDeleteFileAttachments = createCasesRoute({
const caseContext = await context.cases;
const client = await caseContext.getCasesClient();
const requestBody = request.body as BulkDeleteFileAttachmentsRequest;
const requestBody = decodeWithExcessOrThrow(BulkDeleteFileAttachmentsRequestRt)(request.body);
await client.attachments.bulkDeleteFileAttachments({
caseId: request.params.case_id,

View file

@ -6,7 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import type { BulkGetAttachmentsRequest } from '../../../../common/api';
import { BulkGetAttachmentsRequestRt, decodeWithExcessOrThrow } from '../../../../common/api';
import { INTERNAL_BULK_GET_ATTACHMENTS_URL } from '../../../../common/constants';
import { createCaseError } from '../../../common/error';
@ -26,12 +26,13 @@ export const bulkGetAttachmentsRoute = createCasesRoute({
try {
const caseContext = await context.cases;
const client = await caseContext.getCasesClient();
const body = request.body as BulkGetAttachmentsRequest;
const requestBody = decodeWithExcessOrThrow(BulkGetAttachmentsRequestRt)(request.body);
return response.ok({
body: await client.attachments.bulkGet({
caseID: request.params.case_id,
attachmentIDs: body.ids,
attachmentIDs: requestBody.ids,
}),
});
} catch (error) {

View file

@ -13,10 +13,10 @@
* connector.id.
*/
import { omit } from 'lodash';
import { omit, unset } from 'lodash';
import type { CaseAttributes, CaseConnector, CaseFullExternalService } from '../../../common/api';
import { CaseSeverity, CaseStatuses } from '../../../common/api';
import { CASE_SAVED_OBJECT } from '../../../common/constants';
import { CASE_SAVED_OBJECT, SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import type {
SavedObject,
@ -1882,6 +1882,48 @@ describe('CasesService', () => {
'external_service'
);
describe('getCaseIdsByAlertId', () => {
it('strips excess fields', async () => {
const findMockReturn = createSOFindResponse([createFindSO({ caseId: '2' })]);
unsecuredSavedObjectsClient.find.mockResolvedValue(findMockReturn);
const res = await service.getCaseIdsByAlertId({ alertId: '1' });
expect(res).toStrictEqual({
saved_objects: [
{
id: '2',
score: 0,
references: [],
type: CASE_SAVED_OBJECT,
attributes: { owner: SECURITY_SOLUTION_OWNER },
},
],
total: 1,
per_page: 1,
page: 1,
});
});
it('throws an error when the owner field is not present', async () => {
const findMockReturn = createSOFindResponse([createFindSO({ caseId: '2' })]);
unset(findMockReturn, 'saved_objects[0].attributes.owner');
unsecuredSavedObjectsClient.find.mockResolvedValue(findMockReturn);
await expect(
service.getCaseIdsByAlertId({ alertId: '1' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"owner\\""`
);
});
it('does not throw an error when the owner field exists', async () => {
const findMockReturn = createSOFindResponse([createFindSO({ caseId: '2' })]);
unsecuredSavedObjectsClient.find.mockResolvedValue(findMockReturn);
await expect(service.getCaseIdsByAlertId({ alertId: '1' })).resolves.not.toThrow();
});
});
describe('getCase', () => {
it('decodes correctly', async () => {
unsecuredSavedObjectsClient.get.mockResolvedValue(createCaseSavedObjectResponse());
@ -1902,13 +1944,67 @@ describe('CasesService', () => {
}
);
// TODO: Unskip when all types are converted to strict
it.skip('strips out excess attributes', async () => {
const theCase = createCaseSavedObjectResponse();
it('strips out excess attributes', async () => {
const theCase = createCaseSavedObjectResponse({
connector: createESJiraConnector(),
externalService: null,
});
const attributes = { ...theCase.attributes, 'not-exists': 'not-exists' };
unsecuredSavedObjectsClient.get.mockResolvedValue({ ...theCase, attributes });
await expect(service.getCase({ id: 'a' })).resolves.toEqual({ attributes });
await expect(service.getCase({ id: 'a' })).resolves.toMatchInlineSnapshot(`
Object {
"attributes": Object {
"assignees": Array [],
"closed_at": null,
"closed_by": null,
"connector": Object {
"fields": Object {
"issueType": "bug",
"parent": "2",
"priority": "high",
},
"id": "1",
"name": ".jira",
"type": ".jira",
},
"created_at": "2019-11-25T21:54:48.952Z",
"created_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
"description": "This is a brand new case of a bad meanie defacing data",
"duration": null,
"external_service": null,
"owner": "securitySolution",
"settings": Object {
"syncAlerts": true,
},
"severity": "low",
"status": "open",
"tags": Array [
"defacement",
],
"title": "Super Bad Security Issue",
"updated_at": "2019-11-25T21:54:48.952Z",
"updated_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"id": "1",
"references": Array [
Object {
"id": "1",
"name": "connectorId",
"type": "action",
},
],
"type": "cases",
}
`);
});
});
@ -1938,8 +2034,7 @@ describe('CasesService', () => {
}
);
// TODO: Unskip when all types are converted to strict
it.skip('strips out excess attributes', async () => {
it('strips out excess attributes', async () => {
const theCase = createCaseSavedObjectResponse();
const attributes = { ...theCase.attributes, 'not-exists': 'not-exists' };
unsecuredSavedObjectsClient.resolve.mockResolvedValue({
@ -1947,7 +2042,64 @@ describe('CasesService', () => {
outcome: 'exactMatch',
});
await expect(service.getResolveCase({ id: 'a' })).resolves.toEqual({ attributes });
await expect(service.getResolveCase({ id: 'a' })).resolves.toMatchInlineSnapshot(`
Object {
"outcome": "exactMatch",
"saved_object": Object {
"attributes": Object {
"assignees": Array [],
"closed_at": null,
"closed_by": null,
"connector": Object {
"fields": null,
"id": "none",
"name": "none",
"type": ".none",
},
"created_at": "2019-11-25T21:54:48.952Z",
"created_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
"description": "This is a brand new case of a bad meanie defacing data",
"duration": null,
"external_service": Object {
"connector_id": "none",
"connector_name": ".jira",
"external_id": "100",
"external_title": "awesome",
"external_url": "http://www.google.com",
"pushed_at": "2019-11-25T21:54:48.952Z",
"pushed_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"owner": "securitySolution",
"settings": Object {
"syncAlerts": true,
},
"severity": "low",
"status": "open",
"tags": Array [
"defacement",
],
"title": "Super Bad Security Issue",
"updated_at": "2019-11-25T21:54:48.952Z",
"updated_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"id": "1",
"references": Array [],
"type": "cases",
},
}
`);
});
});
@ -2065,15 +2217,72 @@ describe('CasesService', () => {
}
);
// TODO: Unskip when all types are converted to strict
it.skip('strips out excess attributes', async () => {
it('strips out excess attributes', async () => {
const theCase = createCaseSavedObjectResponse();
const attributes = { ...theCase.attributes, 'not-exists': 'not-exists' };
unsecuredSavedObjectsClient.bulkGet.mockResolvedValue({
saved_objects: [{ ...theCase, attributes }],
});
await expect(service.getCases({ caseIds: ['a', 'b'] })).resolves.toEqual({ attributes });
await expect(service.getCases({ caseIds: ['a', 'b'] })).resolves.toMatchInlineSnapshot(`
Object {
"saved_objects": Array [
Object {
"attributes": Object {
"assignees": Array [],
"closed_at": null,
"closed_by": null,
"connector": Object {
"fields": null,
"id": "none",
"name": "none",
"type": ".none",
},
"created_at": "2019-11-25T21:54:48.952Z",
"created_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
"description": "This is a brand new case of a bad meanie defacing data",
"duration": null,
"external_service": Object {
"connector_id": "none",
"connector_name": ".jira",
"external_id": "100",
"external_title": "awesome",
"external_url": "http://www.google.com",
"pushed_at": "2019-11-25T21:54:48.952Z",
"pushed_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"owner": "securitySolution",
"settings": Object {
"syncAlerts": true,
},
"severity": "low",
"status": "open",
"tags": Array [
"defacement",
],
"title": "Super Bad Security Issue",
"updated_at": "2019-11-25T21:54:48.952Z",
"updated_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"id": "1",
"references": Array [],
"type": "cases",
},
],
}
`);
});
});
@ -2107,8 +2316,7 @@ describe('CasesService', () => {
}
);
// TODO: Unskip when all types are converted to strict
it.skip('strips out excess attributes', async () => {
it('strips out excess attributes', async () => {
const theCase = createCaseSavedObjectResponse();
const attributes = { ...theCase.attributes, 'not-exists': 'not-exists' };
const findMockReturn = createSOFindResponse([
@ -2118,7 +2326,123 @@ describe('CasesService', () => {
unsecuredSavedObjectsClient.find.mockResolvedValue(findMockReturn);
await expect(service.findCases()).resolves.toEqual({ attributes });
await expect(service.findCases()).resolves.toMatchInlineSnapshot(`
Object {
"page": 1,
"per_page": 2,
"saved_objects": Array [
Object {
"attributes": Object {
"assignees": Array [],
"closed_at": null,
"closed_by": null,
"connector": Object {
"fields": null,
"id": "none",
"name": "none",
"type": ".none",
},
"created_at": "2019-11-25T21:54:48.952Z",
"created_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
"description": "This is a brand new case of a bad meanie defacing data",
"duration": null,
"external_service": Object {
"connector_id": "none",
"connector_name": ".jira",
"external_id": "100",
"external_title": "awesome",
"external_url": "http://www.google.com",
"pushed_at": "2019-11-25T21:54:48.952Z",
"pushed_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"owner": "securitySolution",
"settings": Object {
"syncAlerts": true,
},
"severity": "low",
"status": "open",
"tags": Array [
"defacement",
],
"title": "Super Bad Security Issue",
"updated_at": "2019-11-25T21:54:48.952Z",
"updated_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"id": "1",
"references": Array [],
"score": 0,
"type": "cases",
},
Object {
"attributes": Object {
"assignees": Array [],
"closed_at": null,
"closed_by": null,
"connector": Object {
"fields": null,
"id": "none",
"name": "none",
"type": ".none",
},
"created_at": "2019-11-25T21:54:48.952Z",
"created_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
"description": "This is a brand new case of a bad meanie defacing data",
"duration": null,
"external_service": Object {
"connector_id": "none",
"connector_name": ".jira",
"external_id": "100",
"external_title": "awesome",
"external_url": "http://www.google.com",
"pushed_at": "2019-11-25T21:54:48.952Z",
"pushed_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"owner": "securitySolution",
"settings": Object {
"syncAlerts": true,
},
"severity": "low",
"status": "open",
"tags": Array [
"defacement",
],
"title": "Super Bad Security Issue",
"updated_at": "2019-11-25T21:54:48.952Z",
"updated_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"id": "1",
"references": Array [],
"score": 0,
"type": "cases",
},
],
"total": 2,
}
`);
});
});
@ -2150,8 +2474,7 @@ describe('CasesService', () => {
}
);
// TODO: Unskip when all types are converted to strict
it.skip('strips out excess attributes', async () => {
it('strips out excess attributes', async () => {
const theCase = createCaseSavedObjectResponse();
const attributes = { ...theCase.attributes, 'not-exists': 'not-exists' };
unsecuredSavedObjectsClient.create.mockResolvedValue({ ...theCase, attributes });
@ -2161,7 +2484,61 @@ describe('CasesService', () => {
attributes: createCasePostParams({ connector: createJiraConnector() }),
id: '1',
})
).resolves.toEqual({ attributes });
).resolves.toMatchInlineSnapshot(`
Object {
"attributes": Object {
"assignees": Array [],
"closed_at": null,
"closed_by": null,
"connector": Object {
"fields": null,
"id": "none",
"name": "none",
"type": ".none",
},
"created_at": "2019-11-25T21:54:48.952Z",
"created_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
"description": "This is a brand new case of a bad meanie defacing data",
"duration": null,
"external_service": Object {
"connector_id": "none",
"connector_name": ".jira",
"external_id": "100",
"external_title": "awesome",
"external_url": "http://www.google.com",
"pushed_at": "2019-11-25T21:54:48.952Z",
"pushed_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"owner": "securitySolution",
"settings": Object {
"syncAlerts": true,
},
"severity": "low",
"status": "open",
"tags": Array [
"defacement",
],
"title": "Super Bad Security Issue",
"updated_at": "2019-11-25T21:54:48.952Z",
"updated_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
"id": "1",
"references": Array [],
"type": "cases",
}
`);
});
});

View file

@ -53,6 +53,7 @@ import {
CaseTransformedAttributesRt,
CasePersistedStatus,
getPartialCaseTransformedAttributesRt,
OwnerRt,
} from '../../common/types/case';
import type {
GetCaseIdsByAlertIdArgs,
@ -134,7 +135,15 @@ export class CasesService {
aggs: this.buildCaseIdsAggs(MAX_DOCS_PER_PAGE),
filter: combinedFilter,
});
return response;
const owners: Array<SavedObjectsFindResult<{ owner: string }>> = [];
for (const so of response.saved_objects) {
const validatedAttributes = decodeOrThrow(OwnerRt)(so.attributes);
owners.push(Object.assign(so, { attributes: validatedAttributes }));
}
return Object.assign(response, { saved_objects: owners });
} catch (error) {
this.log.error(`Error on GET all cases for alert id ${alertId}: ${error}`);
throw error;
@ -199,7 +208,7 @@ export class CasesService {
[status in CaseStatuses]: number;
}> {
const cases = await this.unsecuredSavedObjectsClient.find<
CasePersistedAttributes,
unknown,
{
statuses: {
buckets: Array<{
@ -459,7 +468,7 @@ export class CasesService {
this.log.debug(`Attempting to GET all reporters`);
const results = await this.unsecuredSavedObjectsClient.find<
CasePersistedAttributes,
unknown,
{
reporters: {
buckets: Array<{
@ -521,7 +530,7 @@ export class CasesService {
this.log.debug(`Attempting to GET all cases`);
const results = await this.unsecuredSavedObjectsClient.find<
CasePersistedAttributes,
unknown,
{ tags: { buckets: Array<{ key: string }> } }
>({
type: CASE_SAVED_OBJECT,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { set, omit } from 'lodash';
import { set, omit, unset } from 'lodash';
import { loggerMock } from '@kbn/logging-mocks';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import type {
@ -1599,6 +1599,60 @@ describe('CaseUserActionService', () => {
const pushes = [{ date: new Date(), connectorId: '123' }];
describe('getAll', () => {
it('does not throw when the required fields are present', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValue(
createSOFindResponse([{ ...createUserActionSO(), score: 0 }])
);
await expect(service.getAll('1')).resolves.not.toThrow();
});
it('throws when payload does not exist', async () => {
const findMockReturn = createSOFindResponse([{ ...createUserActionSO(), score: 0 }]);
unset(findMockReturn, 'saved_objects[0].attributes.payload');
unsecuredSavedObjectsClient.find.mockResolvedValue(findMockReturn);
await expect(service.getAll('1')).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"payload\\""`
);
});
it('strips excess fields', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValue(
createSOFindResponse([
{
...createUserActionSO({
attributesOverrides: {
// @ts-expect-error foo is not a valid field for attributesOverrides
foo: 'bar',
},
}),
score: 0,
},
])
);
const res = await service.getAll('1');
expect(res).toStrictEqual(
createSOFindResponse([
{
...createUserActionSO({
attributesOverrides: {
// @ts-expect-error these fields are populated by the legacy transformation logic but aren't valid for the override type
action_id: '100',
case_id: '1',
comment_id: null,
},
}),
score: 0,
},
])
);
});
});
describe('getConnectorFieldsBeforeLatestPush', () => {
const getAggregations = (
userAction: SavedObject<CaseUserActionAttributesWithoutConnectorId>
@ -1677,7 +1731,7 @@ describe('CaseUserActionService', () => {
);
});
it.skip('strips out excess attributes', async () => {
it('strips out excess attributes', async () => {
const userAction = createUserActionSO();
const attributes = { ...userAction.attributes, 'not-exists': 'not-exists' };
const userActionWithExtraAttributes = { ...userAction, attributes, score: 0 };
@ -1687,9 +1741,38 @@ describe('CaseUserActionService', () => {
unsecuredSavedObjectsClient.find.mockResolvedValue({ ...soFindRes, aggregations });
soSerializerMock.rawToSavedObject.mockReturnValue(userActionWithExtraAttributes);
await expect(service.getConnectorFieldsBeforeLatestPush('1', pushes)).resolves.toEqual({
attributes: userAction.attributes,
});
await expect(service.getConnectorFieldsBeforeLatestPush('1', pushes)).resolves
.toMatchInlineSnapshot(`
Map {
"servicenow" => Object {
"attributes": Object {
"action": "create",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"title": "a new title",
},
"type": "title",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"score": 0,
"type": "cases-user-actions",
},
}
`);
});
});
@ -1738,16 +1821,41 @@ describe('CaseUserActionService', () => {
);
});
// TODO: Unskip when all types are converted to strict
it.skip('strips out excess attributes', async () => {
it('strips out excess attributes', async () => {
const userAction = createUserActionSO();
const attributes = { ...userAction.attributes, 'not-exists': 'not-exists' };
const soFindRes = createSOFindResponse([{ ...userAction, attributes, score: 0 }]);
unsecuredSavedObjectsClient.find.mockResolvedValue(soFindRes);
await expect(service.getMostRecentUserAction('123')).resolves.toEqual({
attributes: userAction.attributes,
});
await expect(service.getMostRecentUserAction('123')).resolves.toMatchInlineSnapshot(`
Object {
"attributes": Object {
"action": "create",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"title": "a new title",
},
"type": "title",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"score": 0,
"type": "cases-user-actions",
}
`);
});
});
@ -1840,7 +1948,7 @@ describe('CaseUserActionService', () => {
);
});
it.skip('strips out excess attributes', async () => {
it('strips out excess attributes', async () => {
const userAction = createUserActionSO();
const pushUserAction = pushConnectorUserAction();
const attributes = { ...userAction.attributes, 'not-exists': 'not-exists' };
@ -1851,9 +1959,97 @@ describe('CaseUserActionService', () => {
unsecuredSavedObjectsClient.find.mockResolvedValue({ ...soFindRes, aggregations });
soSerializerMock.rawToSavedObject.mockReturnValue(userActionWithExtraAttributes);
await expect(service.getCaseConnectorInformation('1')).resolves.toEqual({
attributes: userAction.attributes,
});
await expect(service.getCaseConnectorInformation('1')).resolves
.toMatchInlineSnapshot(`
Array [
Object {
"connectorId": undefined,
"fields": Object {
"attributes": Object {
"action": "create",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"title": "a new title",
},
"type": "title",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"score": 0,
"type": "cases-user-actions",
},
"push": Object {
"mostRecent": Object {
"attributes": Object {
"action": "create",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"title": "a new title",
},
"type": "title",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"score": 0,
"type": "cases-user-actions",
},
"oldest": Object {
"attributes": Object {
"action": "create",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"title": "a new title",
},
"type": "title",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"score": 0,
"type": "cases-user-actions",
},
},
},
]
`);
});
});
@ -1945,21 +2141,143 @@ describe('CaseUserActionService', () => {
);
});
it.skip('strips out excess attributes', async () => {
it('strips out excess attributes', async () => {
const userAction = createUserActionSO();
const pushUserAction = pushConnectorUserAction();
const attributes = { ...pushUserAction.attributes, 'not-exists': 'not-exists' };
const pushActionWithExtraAttributes = { ...userAction, attributes, score: 0 };
const pushActionWithExtraAttributes = { ...pushUserAction, attributes, score: 0 };
const aggregations = getAggregations(userAction, pushActionWithExtraAttributes);
const soFindRes = createSOFindResponse([{ ...userAction, score: 0 }]);
unsecuredSavedObjectsClient.find.mockResolvedValue({ ...soFindRes, aggregations });
soSerializerMock.rawToSavedObject.mockReturnValueOnce(userAction);
soSerializerMock.rawToSavedObject.mockReturnValueOnce(pushActionWithExtraAttributes);
soSerializerMock.rawToSavedObject.mockReturnValueOnce(pushActionWithExtraAttributes);
await expect(service.getCaseConnectorInformation('1')).resolves.toEqual({
attributes: userAction.attributes,
});
await expect(service.getCaseConnectorInformation('1')).resolves
.toMatchInlineSnapshot(`
Array [
Object {
"connectorId": undefined,
"fields": Object {
"attributes": Object {
"action": "create",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"title": "a new title",
},
"type": "title",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"type": "cases-user-actions",
},
"push": Object {
"mostRecent": Object {
"attributes": Object {
"action": "push_to_service",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"externalService": Object {
"connector_id": "100",
"connector_name": ".jira",
"external_id": "100",
"external_title": "awesome",
"external_url": "http://www.google.com",
"pushed_at": "2019-11-25T21:54:48.952Z",
"pushed_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
},
"type": "pushed",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "100",
"name": "pushConnectorId",
"type": "action",
},
],
"score": 0,
"type": "cases-user-actions",
},
"oldest": Object {
"attributes": Object {
"action": "push_to_service",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"externalService": Object {
"connector_id": "100",
"connector_name": ".jira",
"external_id": "100",
"external_title": "awesome",
"external_url": "http://www.google.com",
"pushed_at": "2019-11-25T21:54:48.952Z",
"pushed_by": Object {
"email": "testemail@elastic.co",
"full_name": "elastic",
"username": "elastic",
},
},
},
"type": "pushed",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "100",
"name": "pushConnectorId",
"type": "action",
},
],
"score": 0,
"type": "cases-user-actions",
},
},
},
]
`);
});
});
});

View file

@ -5,12 +5,20 @@
* 2.0.
*/
import type { SavedObjectsFindResponse, SavedObjectsRawDoc } from '@kbn/core/server';
import type {
SavedObjectsFindResponse,
SavedObjectsFindResult,
SavedObjectsRawDoc,
} from '@kbn/core/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { KueryNode } from '@kbn/es-query';
import type { CaseUserActionDeprecatedResponse } from '../../../common/api';
import { decodeOrThrow, ActionTypes } from '../../../common/api';
import {
decodeOrThrow,
ActionTypes,
CaseUserActionDeprecatedResponseRt,
} from '../../../common/api';
import {
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
@ -515,10 +523,22 @@ export class CaseUserActionService {
sortOrder: 'asc',
});
return legacyTransformFindResponseToExternalModel(
const transformedUserActions = legacyTransformFindResponseToExternalModel(
userActions,
this.context.persistableStateAttachmentTypeRegistry
);
const validatedUserActions: Array<SavedObjectsFindResult<CaseUserActionDeprecatedResponse>> =
[];
for (const so of transformedUserActions.saved_objects) {
const validatedAttributes = decodeOrThrow(CaseUserActionDeprecatedResponseRt)(
so.attributes
);
validatedUserActions.push(Object.assign(so, { attributes: validatedAttributes }));
}
return Object.assign(transformedUserActions, { saved_objects: validatedUserActions });
} catch (error) {
this.context.log.error(`Error on GET case user action case id: ${caseId}: ${error}`);
throw error;
@ -636,7 +656,7 @@ export class CaseUserActionService {
public async getCaseUserActionStats({ caseId }: { caseId: string }) {
const response = await this.context.unsecuredSavedObjectsClient.find<
UserActionPersistedAttributes,
unknown,
UserActionsStatsAggsResult
>({
type: CASE_USER_ACTION_SAVED_OBJECT,
@ -680,7 +700,7 @@ export class CaseUserActionService {
public async getUsers({ caseId }: { caseId: string }): Promise<GetUsersResponse> {
const response = await this.context.unsecuredSavedObjectsClient.find<
UserActionPersistedAttributes,
unknown,
ParticipantsAggsResult
>({
type: CASE_USER_ACTION_SAVED_OBJECT,

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserActionsService: Finder Decoding: find strips out excess attributes 1`] = `
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"title": "a new title",
},
"type": "title",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`;
exports[`UserActionsService: Finder Decoding: findStatusChanges strips out excess attributes 1`] = `
Array [
Object {
"attributes": Object {
"action": "create",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"title": "a new title",
},
"type": "title",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"score": 0,
"type": "cases-user-actions",
},
]
`;

View file

@ -135,16 +135,13 @@ describe('UserActionsService: Finder', () => {
);
});
// TODO: Unskip when all types are converted to strict
it.skip('strips out excess attributes', async () => {
it('strips out excess attributes', async () => {
const userAction = createUserActionSO();
const attributes = { ...userAction.attributes, 'not-exists': 'not-exists' };
const soFindRes = createSOFindResponse([{ ...userAction, attributes, score: 0 }]);
method(soFindRes);
await expect(finder[soMethodName]({ caseId: '1' })).resolves.toEqual({
attributes: userAction.attributes,
});
await expect(finder[soMethodName]({ caseId: '1' })).resolves.toMatchSnapshot();
});
});
});

View file

@ -70,11 +70,11 @@ export class UserProfileService {
}
public async suggest(request: KibanaRequest): Promise<UserProfile[]> {
const params = decodeWithExcessOrThrow(SuggestUserProfilesRequestRt)(request.body);
const { name, size, owners } = params;
try {
const params = decodeWithExcessOrThrow(SuggestUserProfilesRequestRt)(request.body);
const { name, size, owners } = params;
this.validateInitialization();
const licensingService = new LicensingService(
@ -110,7 +110,7 @@ export class UserProfileService {
} catch (error) {
throw createCaseError({
logger: this.logger,
message: `Failed to retrieve suggested user profiles in service for name: ${name} owners: [${owners}]: ${error}`,
message: `Failed to retrieve suggested user profiles in service: ${error}`,
error,
});
}