[asset_manager] Add io-ts validations to asset endpoints (#157502)

closes https://github.com/elastic/kibana/issues/154147

## Summary

This PR introduces `io-ts` runtime validation to asset manager
endpoints.

The parameters will be validated as follows:
- `maxDistance`: range from 1 to 5
- `size`: range from 1 to 100
- `type`: `k8s.pod` or `k8s.cluster` or `k8s.node`
- `relation`: `ancestors` or `descendants` or `references`
- any date parameter: Date ISO or DateMath formats

### How to test this PR

- enable `xpack.assetManager.alphaEnabled` in kibana.dev.yml
- Call the endpoints below and try to pass invalid parameters where
they're applicable:
- `GET
kbn:/api/asset-manager/assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18:44:44.000Z`
- `GET
kbn:/api/asset-manager/assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFrom=2022-02-07T01:00:00.000Z&bTo=2022-02-07T02:00:00.000Z`
- `GET
kbn:/api/asset-manager/assets/related?ean=k8s.node:node-101&relation=ancestors&maxDistance=1&from=2023-04-18T13:10:13.111Z&to=2023-04-18T15:10:13.111Z`

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2023-05-31 14:36:56 -03:00 committed by GitHub
parent b3561b9328
commit bc041fd467
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 798 additions and 498 deletions

View file

@ -21,10 +21,12 @@ export { toJsonSchema } from './src/to_json_schema';
export { nonEmptyStringRt } from './src/non_empty_string_rt';
export { createLiteralValueFromUndefinedRT } from './src/literal_value_from_undefined_rt';
export { createRouteValidationFunction } from './src/route_validation';
export { inRangeRt, type InRangeBrand, type InRange } from './src/in_range_rt';
export { inRangeRt, type InRangeBrand, type InRange, inRangeFromStringRt } from './src/in_range_rt';
export { dateRt } from './src/date_rt';
export {
isGreaterOrEqualRt,
type IsGreaterOrEqualBrand,
type IsGreaterOrEqual,
} from './src/is_greater_or_equal';
export { datemathStringRt } from './src/datemath_string_rt';

View file

@ -10,15 +10,15 @@ import { dateRt } from '.';
import { isRight } from 'fp-ts/lib/Either';
describe('dateRt', () => {
it('passes if it is a valide date/time', () => {
it('passes if it is a valid date/time', () => {
expect(isRight(dateRt.decode('2019-08-20T11:18:31.407Z'))).toBe(true);
});
it('passes if it is a valide date', () => {
it('passes if it is a valid date', () => {
expect(isRight(dateRt.decode('2019-08-20'))).toBe(true);
});
it('fails if it is an invalide date/time', () => {
it('fails if it is an invalid date/time', () => {
expect(isRight(dateRt.decode('2019-02-30T11:18:31.407Z'))).toBe(false);
});
});

View file

@ -0,0 +1,28 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { datemathStringRt } from '.';
import { isRight } from 'fp-ts/lib/Either';
describe('datemathStringRt', () => {
it('passes if it is a valid dateMath', () => {
expect(isRight(datemathStringRt.decode('now'))).toBe(true);
});
it('fails if it is an invalid dateMath', () => {
expect(isRight(datemathStringRt.decode('now-1l'))).toBe(false);
});
it('fails if it is a ISO date', () => {
expect(isRight(datemathStringRt.decode('2019-02-30T11:18:31.407Z'))).toBe(false);
});
it('fails if it is a letter', () => {
expect(isRight(datemathStringRt.decode('a'))).toBe(false);
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as rt from 'io-ts';
import dateMath from '@kbn/datemath';
import { chain } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
const isValidDatemath = (value: string): boolean => {
const parsedValue = dateMath.parse(value);
return !!(parsedValue && parsedValue.isValid());
};
export const datemathStringRt = new rt.Type<string, string, unknown>(
'datemath',
rt.string.is,
(value, context) =>
pipe(
rt.string.validate(value, context),
chain((stringValue) =>
isValidDatemath(stringValue) ? rt.success(stringValue) : rt.failure(stringValue, context)
)
),
String
);

View file

@ -7,6 +7,7 @@
*/
import * as rt from 'io-ts';
import { toNumberRt } from '../to_number_rt';
export interface InRangeBrand {
readonly InRange: unique symbol;
@ -21,3 +22,7 @@ export const inRangeRt = (start: number, end: number) =>
// refinement of the number type
'InRange' // name of this codec
);
export const inRangeFromStringRt = (start: number, end: number) => {
return toNumberRt.pipe(inRangeRt(start, end));
};

View file

@ -12,7 +12,8 @@
],
"kbn_references": [
"@kbn/config-schema",
"@kbn/core"
"@kbn/core",
"@kbn/datemath"
],
"exclude": [
"target/**/*",

View file

@ -5,14 +5,15 @@
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import * as rt from 'io-ts';
export const assetTypeRT = schema.oneOf([
schema.literal('k8s.pod'),
schema.literal('k8s.cluster'),
schema.literal('k8s.node'),
export const assetTypeRT = rt.union([
rt.literal('k8s.pod'),
rt.literal('k8s.cluster'),
rt.literal('k8s.node'),
]);
export type AssetType = typeof assetTypeRT.type;
export type AssetType = rt.TypeOf<typeof assetTypeRT>;
export type AssetKind = 'unknown' | 'node';
export type AssetStatus =
@ -130,13 +131,13 @@ export interface AssetFilters {
to?: string;
}
export const relationRT = schema.oneOf([
schema.literal('ancestors'),
schema.literal('descendants'),
schema.literal('references'),
export const relationRT = rt.union([
rt.literal('ancestors'),
rt.literal('descendants'),
rt.literal('references'),
]);
export type Relation = typeof relationRT.type;
export type Relation = rt.TypeOf<typeof relationRT>;
export type RelationField = keyof Pick<
Asset,
'asset.children' | 'asset.parents' | 'asset.references'

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types';
import { debug } from '../../common/debug_log';
import { Asset, AssetFilters } from '../../common/types_api';
@ -26,6 +25,66 @@ export async function getAssets({
// Maybe it makes the most sense to validate the filters here?
const { from = 'now-24h', to = 'now' } = filters;
const must: QueryDslQueryContainer[] = [];
if (filters && Object.keys(filters).length > 0) {
if (typeof filters.collectionVersion === 'number') {
must.push({
term: {
['asset.collection_version']: filters.collectionVersion,
},
});
}
if (filters.type?.length) {
must.push({
terms: {
['asset.type']: Array.isArray(filters.type) ? filters.type : [filters.type],
},
});
}
if (filters.kind) {
must.push({
term: {
['asset.kind']: filters.kind,
},
});
}
if (filters.ean) {
must.push({
terms: {
['asset.ean']: Array.isArray(filters.ean) ? filters.ean : [filters.ean],
},
});
}
if (filters.id) {
must.push({
term: {
['asset.id']: filters.id,
},
});
}
if (filters.typeLike) {
must.push({
wildcard: {
['asset.type']: filters.typeLike,
},
});
}
if (filters.eanLike) {
must.push({
wildcard: {
['asset.ean']: filters.eanLike,
},
});
}
}
const dsl: SearchRequest = {
index: ASSETS_INDEX_PREFIX + '*',
size,
@ -41,6 +100,7 @@ export async function getAssets({
},
},
],
must,
},
},
collapse: {
@ -53,74 +113,8 @@ export async function getAssets({
},
};
if (filters && Object.keys(filters).length > 0) {
const musts: QueryDslQueryContainer[] = [];
if (typeof filters.collectionVersion === 'number') {
musts.push({
term: {
['asset.collection_version']: filters.collectionVersion,
},
});
}
if (filters.type?.length) {
musts.push({
terms: {
['asset.type']: Array.isArray(filters.type) ? filters.type : [filters.type],
},
});
}
if (filters.kind) {
musts.push({
term: {
['asset.kind']: filters.kind,
},
});
}
if (filters.ean) {
musts.push({
terms: {
['asset.ean']: Array.isArray(filters.ean) ? filters.ean : [filters.ean],
},
});
}
if (filters.id) {
musts.push({
term: {
['asset.id']: filters.id,
},
});
}
if (filters.typeLike) {
musts.push({
wildcard: {
['asset.type']: filters.typeLike,
},
});
}
if (filters.eanLike) {
musts.push({
wildcard: {
['asset.ean']: filters.eanLike,
},
});
}
if (musts.length > 0) {
dsl.query = dsl.query || {};
dsl.query.bool = dsl.query.bool || {};
dsl.query.bool.must = musts;
}
}
debug('Performing Asset Query', '\n\n', JSON.stringify(dsl, null, 2));
const response = await esClient.search<{}>(dsl);
return response.hits.hits.map((hit) => hit._source as Asset);
const response = await esClient.search<Asset>(dsl);
return response.hits.hits.map((hit) => hit._source).filter((asset): asset is Asset => !!asset);
}

View file

@ -90,8 +90,8 @@ export async function getRelatedAssets({
debug('Performing Asset Query', '\n\n', JSON.stringify(dsl, null, 2));
const response = await esClient.search(dsl);
return response.hits.hits.map((hit) => hit._source as Asset);
const response = await esClient.search<Asset>(dsl);
return response.hits.hits.map((hit) => hit._source).filter((asset): asset is Asset => !!asset);
}
function relationToIndirectField(relation: Relation): RelationField {

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import moment from 'moment';
export function toArray<T>(maybeArray: T | T[] | undefined): T[] {
if (!maybeArray) {
return [];
@ -14,3 +16,10 @@ export function toArray<T>(maybeArray: T | T[] | undefined): T[] {
}
return [maybeArray];
}
export const isValidRange = (from: string, to: string): boolean => {
if (moment(from).isAfter(to)) {
return false;
}
return true;
};

View file

@ -5,9 +5,16 @@
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { RequestHandlerContext } from '@kbn/core/server';
import { differenceBy, intersectionBy } from 'lodash';
import * as rt from 'io-ts';
import {
dateRt,
inRangeFromStringRt,
datemathStringRt,
createRouteValidationFunction,
createLiteralValueFromUndefinedRT,
} from '@kbn/io-ts-utils';
import { debug } from '../../common/debug_log';
import { AssetType, assetTypeRT, relationRT } from '../../common/types_api';
import { ASSET_MANAGER_API_BASE } from '../constants';
@ -16,47 +23,61 @@ import { getAllRelatedAssets } from '../lib/get_all_related_assets';
import { SetupRouteOptions } from './types';
import { getEsClientFromContext } from './utils';
import { AssetNotFoundError } from '../lib/errors';
import { toArray } from '../lib/utils';
import { isValidRange, toArray } from '../lib/utils';
const assetType = schema.oneOf([
schema.literal('k8s.pod'),
schema.literal('k8s.cluster'),
schema.literal('k8s.node'),
]);
const sizeRT = rt.union([inRangeFromStringRt(1, 100), createLiteralValueFromUndefinedRT(10)]);
const assetDateRT = rt.union([dateRt, datemathStringRt]);
const getAssetsQueryOptionsRT = rt.exact(
rt.partial({
from: assetDateRT,
to: assetDateRT,
type: rt.union([rt.array(assetTypeRT), assetTypeRT]),
ean: rt.union([rt.array(rt.string), rt.string]),
size: sizeRT,
})
);
const getAssetsQueryOptions = schema.object({
from: schema.maybe(schema.string()),
to: schema.maybe(schema.string()),
type: schema.maybe(schema.oneOf([schema.arrayOf(assetTypeRT), assetTypeRT])),
ean: schema.maybe(schema.oneOf([schema.arrayOf(schema.string()), schema.string()])),
size: schema.maybe(schema.number()),
});
const getAssetsDiffQueryOptionsRT = rt.exact(
rt.intersection([
rt.type({
aFrom: assetDateRT,
aTo: assetDateRT,
bFrom: assetDateRT,
bTo: assetDateRT,
}),
rt.partial({
type: rt.union([rt.array(assetTypeRT), assetTypeRT]),
}),
])
);
const getAssetsDiffQueryOptions = schema.object({
aFrom: schema.string(),
aTo: schema.string(),
bFrom: schema.string(),
bTo: schema.string(),
type: schema.maybe(schema.oneOf([schema.arrayOf(assetType), assetType])),
});
const getRelatedAssetsQueryOptionsRT = rt.exact(
rt.intersection([
rt.type({
from: assetDateRT,
ean: rt.string,
relation: relationRT,
size: sizeRT,
maxDistance: rt.union([inRangeFromStringRt(1, 5), createLiteralValueFromUndefinedRT(1)]),
}),
rt.partial({
to: assetDateRT,
type: rt.union([rt.array(assetTypeRT), assetTypeRT]),
}),
])
);
const getRelatedAssetsQueryOptions = schema.object({
from: schema.string(), // ISO timestamp or ES datemath
to: schema.maybe(schema.string()), // ISO timestamp or ES datemath
ean: schema.string(),
relation: relationRT,
type: schema.maybe(schema.oneOf([assetTypeRT, schema.arrayOf(assetTypeRT)])),
maxDistance: schema.maybe(schema.number()),
size: schema.maybe(schema.number()),
});
export type GetAssetsQueryOptions = rt.TypeOf<typeof getAssetsQueryOptionsRT>;
export type GetRelatedAssetsQueryOptions = rt.TypeOf<typeof getRelatedAssetsQueryOptionsRT>;
export type GetAssetsDiffQueryOptions = rt.TypeOf<typeof getAssetsDiffQueryOptionsRT>;
export function assetsRoutes<T extends RequestHandlerContext>({ router }: SetupRouteOptions<T>) {
// GET /assets
router.get<unknown, typeof getAssetsQueryOptions.type, unknown>(
router.get<unknown, GetAssetsQueryOptions, unknown>(
{
path: `${ASSET_MANAGER_API_BASE}/assets`,
validate: {
query: getAssetsQueryOptions,
query: createRouteValidationFunction(getAssetsQueryOptionsRT),
},
},
async (context, req, res) => {
@ -81,24 +102,26 @@ export function assetsRoutes<T extends RequestHandlerContext>({ router }: SetupR
);
// GET assets/related
router.get<unknown, typeof getRelatedAssetsQueryOptions.type, unknown>(
router.get<unknown, GetRelatedAssetsQueryOptions, unknown>(
{
path: `${ASSET_MANAGER_API_BASE}/assets/related`,
validate: {
query: getRelatedAssetsQueryOptions,
query: createRouteValidationFunction(getRelatedAssetsQueryOptionsRT),
},
},
async (context, req, res) => {
// Add references into sample data and write integration tests
const { from, to, ean, relation } = req.query || {};
const { from, to, ean, relation, maxDistance, size } = req.query || {};
const esClient = await getEsClientFromContext(context);
// What if maxDistance is below 1?
const maxDistance = req.query.maxDistance ? Math.min(req.query.maxDistance, 5) : 1; // Validate maxDistance not larger than 5
const size = req.query.size ? Math.min(req.query.size, 100) : 10; // Do we need pagination and sorting? Yes.
const type = toArray<AssetType>(req.query.type);
// Validate from and to to be ISO string only. Or use io-ts to coerce.
if (to && !isValidRange(from, to)) {
return res.badRequest({
body: `Time range cannot move backwards in time. "to" (${to}) is before "from" (${from}).`,
});
}
try {
return res.ok({
@ -125,24 +148,24 @@ export function assetsRoutes<T extends RequestHandlerContext>({ router }: SetupR
);
// GET /assets/diff
router.get<unknown, typeof getAssetsDiffQueryOptions.type, unknown>(
router.get<unknown, GetAssetsDiffQueryOptions, unknown>(
{
path: `${ASSET_MANAGER_API_BASE}/assets/diff`,
validate: {
query: getAssetsDiffQueryOptions,
query: createRouteValidationFunction(getAssetsDiffQueryOptionsRT),
},
},
async (context, req, res) => {
const { aFrom, aTo, bFrom, bTo } = req.query;
const type = toArray<AssetType>(req.query.type);
if (new Date(aFrom) > new Date(aTo)) {
if (!isValidRange(aFrom, aTo)) {
return res.badRequest({
body: `Time range cannot move backwards in time. "aTo" (${aTo}) is before "aFrom" (${aFrom}).`,
});
}
if (new Date(bFrom) > new Date(bTo)) {
if (!isValidRange(bFrom, bTo)) {
return res.badRequest({
body: `Time range cannot move backwards in time. "bTo" (${bTo}) is before "bFrom" (${bFrom}).`,
});

View file

@ -16,5 +16,6 @@
"@kbn/config-schema",
"@kbn/core-http-server",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/io-ts-utils",
]
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export const ASSETS_ENDPOINT = '/api/asset-manager/assets';

View file

@ -11,5 +11,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./tests/basics'));
loadTestFile(require.resolve('./tests/sample_assets'));
loadTestFile(require.resolve('./tests/assets'));
loadTestFile(require.resolve('./tests/assets_diff'));
loadTestFile(require.resolve('./tests/assets_related'));
});
}

View file

@ -5,16 +5,11 @@
* 2.0.
*/
import { pick, sortBy } from 'lodash';
import { Asset, AssetWithoutTimestamp } from '@kbn/assetManager-plugin/common/types_api';
import { AssetWithoutTimestamp } from '@kbn/assetManager-plugin/common/types_api';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { createSampleAssets, deleteSampleAssets, viewSampleAssetDocs } from '../helpers';
const ASSETS_ENDPOINT = '/api/asset-manager/assets';
const DIFF_ENDPOINT = `${ASSETS_ENDPOINT}/diff`;
const RELATED_ASSETS_ENDPOINT = `${ASSETS_ENDPOINT}/related`;
import { ASSETS_ENDPOINT } from '../constants';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@ -182,380 +177,32 @@ export default function ({ getService }: FtrProviderContext) {
// The order of the expected assets is fixed
expect(getResponse.body.results).to.eql(targetAssets);
});
});
describe('GET /assets/diff', () => {
it('should reject requests that do not include the two time ranges to compare', async () => {
const timestamp = new Date().toISOString();
it('should reject requests with negative size parameter', async () => {
const getResponse = await supertest.get(ASSETS_ENDPOINT).query({ size: -1 }).expect(400);
let getResponse = await supertest.get(DIFF_ENDPOINT).expect(400);
expect(getResponse.body.message).to.equal(
'[request query.aFrom]: expected value of type [string] but got [undefined]'
'[request query]: Failed to validate: \n in /size: -1 does not match expected type pipe(ToNumber, InRange)\n in /size: "-1" does not match expected type pipe(undefined, BooleanFromString)'
);
getResponse = await supertest.get(DIFF_ENDPOINT).query({ aFrom: timestamp }).expect(400);
expect(getResponse.body.message).to.equal(
'[request query.aTo]: expected value of type [string] but got [undefined]'
);
getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({ aFrom: timestamp, aTo: timestamp })
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query.bFrom]: expected value of type [string] but got [undefined]'
);
getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({ aFrom: timestamp, aTo: timestamp, bFrom: timestamp })
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query.bTo]: expected value of type [string] but got [undefined]'
);
await supertest
.get(DIFF_ENDPOINT)
.query({ aFrom: timestamp, aTo: timestamp, bFrom: timestamp, bTo: timestamp })
.expect(200);
});
it('should reject requests where either time range is moving backwards in time', async () => {
const now = new Date();
const isoNow = now.toISOString();
const oneHourAgo = new Date(now.getTime() - 1000 * 60 * 60 * 1).toISOString();
it('should reject requests with size parameter greater than 100', async () => {
const getResponse = await supertest.get(ASSETS_ENDPOINT).query({ size: 101 }).expect(400);
let getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({
aFrom: isoNow,
aTo: oneHourAgo,
bFrom: isoNow,
bTo: isoNow,
})
.expect(400);
expect(getResponse.body.message).to.equal(
`Time range cannot move backwards in time. "aTo" (${oneHourAgo}) is before "aFrom" (${isoNow}).`
'[request query]: Failed to validate: \n in /size: 101 does not match expected type pipe(ToNumber, InRange)\n in /size: "101" does not match expected type pipe(undefined, BooleanFromString)'
);
getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({
aFrom: isoNow,
aTo: isoNow,
bFrom: isoNow,
bTo: oneHourAgo,
})
.expect(400);
expect(getResponse.body.message).to.equal(
`Time range cannot move backwards in time. "bTo" (${oneHourAgo}) is before "bFrom" (${isoNow}).`
);
await supertest
.get(DIFF_ENDPOINT)
.query({
aFrom: oneHourAgo,
aTo: isoNow,
bFrom: oneHourAgo,
bTo: isoNow,
})
.expect(200);
});
it('should return the difference in assets present between two time ranges', async () => {
const onlyInA = sampleAssetDocs.slice(0, 2);
const onlyInB = sampleAssetDocs.slice(sampleAssetDocs.length - 2);
const inBoth = sampleAssetDocs.slice(2, sampleAssetDocs.length - 2);
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 1000 * 60 * 60 * 1);
const twoHoursAgo = new Date(now.getTime() - 1000 * 60 * 60 * 2);
await createSampleAssets(supertest, {
baseDateTime: twoHoursAgo.toISOString(),
excludeEans: inBoth.concat(onlyInB).map((asset) => asset['asset.ean']),
});
await createSampleAssets(supertest, {
baseDateTime: oneHourAgo.toISOString(),
excludeEans: onlyInA.concat(onlyInB).map((asset) => asset['asset.ean']),
});
await createSampleAssets(supertest, {
excludeEans: inBoth.concat(onlyInA).map((asset) => asset['asset.ean']),
});
const twoHoursAndTenMinuesAgo = new Date(now.getTime() - 1000 * 60 * 130 * 1);
const fiftyMinuesAgo = new Date(now.getTime() - 1000 * 60 * 50 * 1);
const seventyMinuesAgo = new Date(now.getTime() - 1000 * 60 * 70 * 1);
const tenMinutesAfterNow = new Date(now.getTime() + 1000 * 60 * 10);
it('should reject requests with invalid from and to parameters', async () => {
const getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({
aFrom: twoHoursAndTenMinuesAgo,
aTo: fiftyMinuesAgo,
bFrom: seventyMinuesAgo,
bTo: tenMinutesAfterNow,
})
.expect(200);
.get(ASSETS_ENDPOINT)
.query({ from: 'now_1p', to: 'now_1p' })
.expect(400);
expect(getResponse.body).to.have.property('onlyInA');
expect(getResponse.body).to.have.property('onlyInB');
expect(getResponse.body).to.have.property('inBoth');
getResponse.body.onlyInA.forEach((asset: any) => {
delete asset['@timestamp'];
});
getResponse.body.onlyInB.forEach((asset: any) => {
delete asset['@timestamp'];
});
getResponse.body.inBoth.forEach((asset: any) => {
delete asset['@timestamp'];
});
const sortByEan = (assets: any[]) => sortBy(assets, (asset) => asset['asset.ean']);
expect(sortByEan(getResponse.body.onlyInA)).to.eql(sortByEan(onlyInA));
expect(sortByEan(getResponse.body.onlyInB)).to.eql(sortByEan(onlyInB));
expect(sortByEan(getResponse.body.inBoth)).to.eql(sortByEan(inBoth));
});
});
describe('GET /assets/related', () => {
describe('basic validation of all relations', () => {
const relations = [
{
name: 'ancestors',
ean: 'k8s.node:node-101',
expectedRelatedEans: ['k8s.cluster:cluster-001'],
},
{
name: 'descendants',
ean: 'k8s.cluster:cluster-001',
expectedRelatedEans: ['k8s.node:node-101', 'k8s.node:node-102', 'k8s.node:node-103'],
},
{
name: 'references',
ean: 'k8s.pod:pod-200xrg1',
expectedRelatedEans: ['k8s.cluster:cluster-001'],
},
];
relations.forEach((relation) => {
it(`should return the ${relation.name} assets`, async () => {
await createSampleAssets(supertest);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: relation.name,
size: sampleAssetDocs.length,
from: 'now-1d',
ean: relation.ean,
maxDistance: 1,
})
.expect(200);
const relatedEans = getResponse.body.results[relation.name].map(
(asset: Asset) => asset['asset.ean']
);
expect(relatedEans).to.eql(relation.expectedRelatedEans);
});
});
});
describe('response validation', () => {
it('should return 404 if primary asset not found', async () => {
await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: 'non-existing-ean',
maxDistance: 5,
})
.expect(404);
});
it('should return the primary asset', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-002'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 5,
})
.expect(200);
const {
body: { results },
} = getResponse;
delete results.primary['@timestamp'];
expect(results.primary).to.eql(sampleCluster);
});
it('should return empty assets when none matching', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-002'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 5,
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results).to.have.property('descendants');
expect(results.descendants).to.have.length(0);
});
it('breaks circular dependency', async () => {
await createSampleAssets(supertest);
// pods reference a node that references the pods
const sampleNode = sampleAssetDocs.find((asset) => asset['asset.id'] === 'pod-203ugg5');
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'references',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleNode!['asset.ean'],
maxDistance: 5,
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(
results.references.map((asset: Asset) => pick(asset, ['asset.ean', 'distance']))
).to.eql([
{ 'asset.ean': 'k8s.node:node-203', distance: 1 },
{ 'asset.ean': 'k8s.pod:pod-203ugg9', distance: 2 },
]);
});
});
describe('no asset.type filters', () => {
it('should return all descendants of a provided ean at maxDistance 1', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-001'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 1,
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results.descendants).to.have.length(3);
expect(results.descendants.every((asset: { distance: number }) => asset.distance === 1));
});
it('should return all descendants of a provided ean at maxDistance 2', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-001'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 2,
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results.descendants).to.have.length(12);
});
});
describe('with asset.type filters', () => {
it('should filter by the provided asset type', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-001'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 1,
type: ['k8s.pod'],
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results.descendants).to.have.length(0);
});
it('should return all descendants of a provided ean at maxDistance 2', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-001'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 2,
type: ['k8s.pod'],
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results.descendants).to.have.length(9);
expect(results.descendants.every((asset: { distance: number }) => asset.distance === 2));
expect(results.descendants.every((asset: Asset) => asset['asset.type'] === 'k8s.pod'));
});
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /from: "now_1p" does not match expected type Date\n in /from: "now_1p" does not match expected type datemath\n in /to: "now_1p" does not match expected type Date\n in /to: "now_1p" does not match expected type datemath'
);
});
});
});

View file

@ -0,0 +1,175 @@
/*
* 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 { sortBy } from 'lodash';
import { AssetWithoutTimestamp } from '@kbn/assetManager-plugin/common/types_api';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { createSampleAssets, deleteSampleAssets, viewSampleAssetDocs } from '../helpers';
import { ASSETS_ENDPOINT } from '../constants';
const DIFF_ENDPOINT = `${ASSETS_ENDPOINT}/diff`;
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('asset management', () => {
let sampleAssetDocs: AssetWithoutTimestamp[] = [];
before(async () => {
sampleAssetDocs = await viewSampleAssetDocs(supertest);
});
beforeEach(async () => {
await deleteSampleAssets(supertest);
});
describe('GET /assets/diff', () => {
it('should reject requests that do not include the two time ranges to compare', async () => {
const timestamp = new Date().toISOString();
let getResponse = await supertest.get(DIFF_ENDPOINT).expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/aFrom: undefined does not match expected type Date\n in /0/aFrom: undefined does not match expected type datemath\n in /0/aTo: undefined does not match expected type Date\n in /0/aTo: undefined does not match expected type datemath\n in /0/bFrom: undefined does not match expected type Date\n in /0/bFrom: undefined does not match expected type datemath\n in /0/bTo: undefined does not match expected type Date\n in /0/bTo: undefined does not match expected type datemath'
);
getResponse = await supertest.get(DIFF_ENDPOINT).query({ aFrom: timestamp }).expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/aTo: undefined does not match expected type Date\n in /0/aTo: undefined does not match expected type datemath\n in /0/bFrom: undefined does not match expected type Date\n in /0/bFrom: undefined does not match expected type datemath\n in /0/bTo: undefined does not match expected type Date\n in /0/bTo: undefined does not match expected type datemath'
);
getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({ aFrom: timestamp, aTo: timestamp })
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/bFrom: undefined does not match expected type Date\n in /0/bFrom: undefined does not match expected type datemath\n in /0/bTo: undefined does not match expected type Date\n in /0/bTo: undefined does not match expected type datemath'
);
getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({ aFrom: timestamp, aTo: timestamp, bFrom: timestamp })
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/bTo: undefined does not match expected type Date\n in /0/bTo: undefined does not match expected type datemath'
);
await supertest
.get(DIFF_ENDPOINT)
.query({ aFrom: timestamp, aTo: timestamp, bFrom: timestamp, bTo: timestamp })
.expect(200);
});
it('should reject requests where either time range is moving backwards in time', async () => {
const now = new Date();
const isoNow = now.toISOString();
const oneHourAgo = new Date(now.getTime() - 1000 * 60 * 60 * 1).toISOString();
let getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({
aFrom: isoNow,
aTo: oneHourAgo,
bFrom: isoNow,
bTo: isoNow,
})
.expect(400);
expect(getResponse.body.message).to.equal(
`Time range cannot move backwards in time. "aTo" (${oneHourAgo}) is before "aFrom" (${isoNow}).`
);
getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({
aFrom: isoNow,
aTo: isoNow,
bFrom: isoNow,
bTo: oneHourAgo,
})
.expect(400);
expect(getResponse.body.message).to.equal(
`Time range cannot move backwards in time. "bTo" (${oneHourAgo}) is before "bFrom" (${isoNow}).`
);
await supertest
.get(DIFF_ENDPOINT)
.query({
aFrom: oneHourAgo,
aTo: isoNow,
bFrom: oneHourAgo,
bTo: isoNow,
})
.expect(200);
});
it('should return the difference in assets present between two time ranges', async () => {
const onlyInA = sampleAssetDocs.slice(0, 2);
const onlyInB = sampleAssetDocs.slice(sampleAssetDocs.length - 2);
const inBoth = sampleAssetDocs.slice(2, sampleAssetDocs.length - 2);
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 1000 * 60 * 60 * 1);
const twoHoursAgo = new Date(now.getTime() - 1000 * 60 * 60 * 2);
await createSampleAssets(supertest, {
baseDateTime: twoHoursAgo.toISOString(),
excludeEans: inBoth.concat(onlyInB).map((asset) => asset['asset.ean']),
});
await createSampleAssets(supertest, {
baseDateTime: oneHourAgo.toISOString(),
excludeEans: onlyInA.concat(onlyInB).map((asset) => asset['asset.ean']),
});
await createSampleAssets(supertest, {
excludeEans: inBoth.concat(onlyInA).map((asset) => asset['asset.ean']),
});
const twoHoursAndTenMinuesAgo = new Date(now.getTime() - 1000 * 60 * 130 * 1);
const fiftyMinuesAgo = new Date(now.getTime() - 1000 * 60 * 50 * 1);
const seventyMinuesAgo = new Date(now.getTime() - 1000 * 60 * 70 * 1);
const tenMinutesAfterNow = new Date(now.getTime() + 1000 * 60 * 10);
const getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({
aFrom: twoHoursAndTenMinuesAgo,
aTo: fiftyMinuesAgo,
bFrom: seventyMinuesAgo,
bTo: tenMinutesAfterNow,
})
.expect(200);
expect(getResponse.body).to.have.property('onlyInA');
expect(getResponse.body).to.have.property('onlyInB');
expect(getResponse.body).to.have.property('inBoth');
getResponse.body.onlyInA.forEach((asset: any) => {
delete asset['@timestamp'];
});
getResponse.body.onlyInB.forEach((asset: any) => {
delete asset['@timestamp'];
});
getResponse.body.inBoth.forEach((asset: any) => {
delete asset['@timestamp'];
});
const sortByEan = (assets: any[]) => sortBy(assets, (asset) => asset['asset.ean']);
expect(sortByEan(getResponse.body.onlyInA)).to.eql(sortByEan(onlyInA));
expect(sortByEan(getResponse.body.onlyInB)).to.eql(sortByEan(onlyInB));
expect(sortByEan(getResponse.body.inBoth)).to.eql(sortByEan(inBoth));
});
it('should reject requests with invalid datemath', async () => {
const getResponse = await supertest
.get(DIFF_ENDPOINT)
.query({ aFrom: 'now_1p', aTo: 'now_1p', bFrom: 'now_1p', bTo: 'now_1p' })
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/aFrom: "now_1p" does not match expected type Date\n in /0/aFrom: "now_1p" does not match expected type datemath\n in /0/aTo: "now_1p" does not match expected type Date\n in /0/aTo: "now_1p" does not match expected type datemath\n in /0/bFrom: "now_1p" does not match expected type Date\n in /0/bFrom: "now_1p" does not match expected type datemath\n in /0/bTo: "now_1p" does not match expected type Date\n in /0/bTo: "now_1p" does not match expected type datemath'
);
});
});
});
}

View file

@ -0,0 +1,375 @@
/*
* 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 { pick } from 'lodash';
import { Asset, AssetWithoutTimestamp } from '@kbn/assetManager-plugin/common/types_api';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { createSampleAssets, deleteSampleAssets, viewSampleAssetDocs } from '../helpers';
import { ASSETS_ENDPOINT } from '../constants';
const RELATED_ASSETS_ENDPOINT = `${ASSETS_ENDPOINT}/related`;
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('asset management', () => {
let sampleAssetDocs: AssetWithoutTimestamp[] = [];
let relatedAssetBaseQuery = {};
before(async () => {
sampleAssetDocs = await viewSampleAssetDocs(supertest);
relatedAssetBaseQuery = {
size: sampleAssetDocs.length,
from: 'now-1d',
maxDistance: 5,
};
});
beforeEach(async () => {
await deleteSampleAssets(supertest);
});
describe('GET /assets/related', () => {
describe('basic validation of all relations', () => {
const relations = [
{
name: 'ancestors',
ean: 'k8s.node:node-101',
expectedRelatedEans: ['k8s.cluster:cluster-001'],
},
{
name: 'descendants',
ean: 'k8s.cluster:cluster-001',
expectedRelatedEans: ['k8s.node:node-101', 'k8s.node:node-102', 'k8s.node:node-103'],
},
{
name: 'references',
ean: 'k8s.pod:pod-200xrg1',
expectedRelatedEans: ['k8s.cluster:cluster-001'],
},
];
relations.forEach((relation) => {
it(`should return the ${relation.name} assets`, async () => {
await createSampleAssets(supertest);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: relation.name,
size: sampleAssetDocs.length,
from: 'now-1d',
ean: relation.ean,
maxDistance: 1,
})
.expect(200);
const relatedEans = getResponse.body.results[relation.name].map(
(asset: Asset) => asset['asset.ean']
);
expect(relatedEans).to.eql(relation.expectedRelatedEans);
});
});
});
describe('response validation', () => {
it('should return 404 if primary asset not found', async () => {
await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'descendants',
ean: 'non-existing-ean',
})
.expect(404);
});
it('should return the primary asset', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-002'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'descendants',
ean: sampleCluster!['asset.ean'],
})
.expect(200);
const {
body: { results },
} = getResponse;
delete results.primary['@timestamp'];
expect(results.primary).to.eql(sampleCluster);
});
it('should return empty assets when none matching', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-002'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'descendants',
ean: sampleCluster!['asset.ean'],
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results).to.have.property('descendants');
expect(results.descendants).to.have.length(0);
});
it('breaks circular dependency', async () => {
await createSampleAssets(supertest);
// pods reference a node that references the pods
const sampleNode = sampleAssetDocs.find((asset) => asset['asset.id'] === 'pod-203ugg5');
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'references',
ean: sampleNode!['asset.ean'],
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(
results.references.map((asset: Asset) => pick(asset, ['asset.ean', 'distance']))
).to.eql([
{ 'asset.ean': 'k8s.node:node-203', distance: 1 },
{ 'asset.ean': 'k8s.pod:pod-203ugg9', distance: 2 },
]);
});
it('should reject requests with negative size parameter', async () => {
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'descendants',
ean: 'non-existing-ean',
size: -1,
})
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/size: -1 does not match expected type pipe(ToNumber, InRange)\n in /0/size: "-1" does not match expected type pipe(undefined, BooleanFromString)'
);
});
it('should reject requests with size parameter greater than 100', async () => {
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'descendants',
ean: 'non-existing-ean',
size: 101,
})
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/size: 101 does not match expected type pipe(ToNumber, InRange)\n in /0/size: "101" does not match expected type pipe(undefined, BooleanFromString)'
);
});
it('should reject requests with negative maxDistance parameter', async () => {
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'descendants',
ean: 'non-existing-ean',
maxDistance: -1,
})
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/maxDistance: -1 does not match expected type pipe(ToNumber, InRange)\n in /0/maxDistance: "-1" does not match expected type pipe(undefined, BooleanFromString)'
);
});
it('should reject requests with size parameter maxDistance is greater than 5', async () => {
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'descendants',
ean: 'non-existing-ean',
maxDistance: 6,
})
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/maxDistance: 6 does not match expected type pipe(ToNumber, InRange)\n in /0/maxDistance: "6" does not match expected type pipe(undefined, BooleanFromString)'
);
});
it('should reject requests with invalid from and to parameters', async () => {
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'descendants',
ean: 'non-existing-ean',
from: 'now_1p',
to: 'now_1p',
})
.expect(400);
expect(getResponse.body.message).to.equal(
'[request query]: Failed to validate: \n in /0/from: "now_1p" does not match expected type Date\n in /0/from: "now_1p" does not match expected type datemath\n in /1/to: "now_1p" does not match expected type Date\n in /1/to: "now_1p" does not match expected type datemath'
);
});
it('should reject requests where time range is moving backwards in time', async () => {
const now = new Date();
const isoNow = now.toISOString();
const oneHourAgo = new Date(now.getTime() - 1000 * 60 * 60 * 1).toISOString();
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
...relatedAssetBaseQuery,
relation: 'descendants',
from: isoNow,
to: oneHourAgo,
maxDistance: 1,
ean: 'non-existing-ean',
})
.expect(400);
expect(getResponse.body.message).to.equal(
`Time range cannot move backwards in time. "to" (${oneHourAgo}) is before "from" (${isoNow}).`
);
});
});
describe('no asset.type filters', () => {
it('should return all descendants of a provided ean at maxDistance 1', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-001'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 1,
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results.descendants).to.have.length(3);
expect(results.descendants.every((asset: { distance: number }) => asset.distance === 1));
});
it('should return all descendants of a provided ean at maxDistance 2', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-001'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 2,
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results.descendants).to.have.length(12);
});
});
describe('with asset.type filters', () => {
it('should filter by the provided asset type', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-001'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 1,
type: ['k8s.pod'],
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results.descendants).to.have.length(0);
});
it('should return all descendants of a provided ean at maxDistance 2', async () => {
await createSampleAssets(supertest);
const sampleCluster = sampleAssetDocs.find(
(asset) => asset['asset.id'] === 'cluster-001'
);
const getResponse = await supertest
.get(RELATED_ASSETS_ENDPOINT)
.query({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 2,
type: ['k8s.pod'],
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(results.descendants).to.have.length(9);
expect(results.descendants.every((asset: { distance: number }) => asset.distance === 2));
expect(results.descendants.every((asset: Asset) => asset['asset.type'] === 'k8s.pod'));
});
});
});
});
}