Skip to content

Commit

Permalink
Fixes #263 - Introduce custom Jest test matcher for matching collecte…
Browse files Browse the repository at this point in the history
…d entities against a well-defined schema
  • Loading branch information
Austin Kelleher committed Aug 2, 2020
1 parent c8ef69a commit fcab7e6
Show file tree
Hide file tree
Showing 3 changed files with 360 additions and 0 deletions.
171 changes: 171 additions & 0 deletions packages/integration-sdk-testing/src/__tests__/jest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { Entity } from '@jupiterone/integration-sdk-core';
import { toMatchGraphObjectSchema, GraphObjectSchema } from '../jest';

function generateCollectedEntity(partial?: Partial<Entity>): Entity {
return {
name: 'appengine.googleapis.com',
_class: ['Service'],
_type: 'google_cloud_api_service',
_key:
'google_cloud_api_service_projects/123/services/appengine.googleapis.com',
displayName: 'App Engine Admin API',
category: ['infrastructure'],
description: "Provisions and manages developers' App Engine applications.",
state: 'ENABLED',
enabled: true,
usageRequirements: ['serviceusage.googleapis.com/tos/cloud'],
_rawData: [
{
name: 'default',
rawData: {
name: 'projects/123/services/appengine.googleapis.com',
config: {
name: 'appengine.googleapis.com',
title: 'App Engine Admin API',
documentation: {
summary:
"Provisions and manages developers' App Engine applications.",
},
quota: {},
authentication: {},
usage: {
requirements: ['serviceusage.googleapis.com/tos/cloud'],
},
},
state: 'ENABLED',
parent: 'projects/123',
},
},
],
...partial,
};
}

function generateGraphObjectSchema(
partialProperties?: Record<string, any>,
): GraphObjectSchema {
return {
additionalProperties: false,
properties: {
_type: { const: 'google_cloud_api_service' },
category: { const: ['infrastructure'] },
state: {
type: 'string',
enum: ['STATE_UNSPECIFIED', 'DISABLED', 'ENABLED'],
},
enabled: { type: 'boolean' },
usageRequirements: {
type: 'array',
items: { type: 'string' },
},
_rawData: {
type: 'array',
items: { type: 'object' },
},
...partialProperties,
},
};
}

describe('#toMatchGraphObjectSchema', () => {
test('should match custom entity schema with single class', () => {
const result = toMatchGraphObjectSchema(generateCollectedEntity(), {
_class: 'Service',
schema: generateGraphObjectSchema(),
});

expect(result).toEqual({
message: expect.any(Function),
pass: true,
});

expect(result.message()).toEqual('Success!');
});

test('should match custom entity schema with array of classes', () => {
const result = toMatchGraphObjectSchema(generateCollectedEntity(), {
_class: ['Service'],
schema: generateGraphObjectSchema(),
});

expect(result).toEqual({
message: expect.any(Function),
pass: true,
});

expect(result.message()).toEqual('Success!');
});

test('should match array of custom entities using schema', () => {
const result = toMatchGraphObjectSchema(
[generateCollectedEntity(), generateCollectedEntity()],
{
_class: ['Service'],
schema: generateGraphObjectSchema(),
},
);

expect(result).toEqual({
message: expect.any(Function),
pass: true,
});

expect(result.message()).toEqual('Success!');
});

test('should not pass if entity does not match schema', () => {
const data = generateCollectedEntity();
const result = toMatchGraphObjectSchema(data, {
_class: ['Service'],
schema: generateGraphObjectSchema({
enabled: { type: 'string' },
}),
});

expect(result).toEqual({
message: expect.any(Function),
pass: false,
});

const expectedSerialzedErrors = JSON.stringify(
[
{
keyword: 'type',
dataPath: '.enabled',
schemaPath: '#/properties/enabled/type',
params: {
type: 'string',
},
message: 'should be string',
},
],
null,
2,
);

expect(result.message()).toEqual(
`Error validating graph object against schema (data=${JSON.stringify(
data,
null,
2,
)}, errors=${expectedSerialzedErrors}, index=0)`,
);
});

test('should not pass if using an unknown class', () => {
const data = generateCollectedEntity();
const result = toMatchGraphObjectSchema(data, {
_class: ['INVALID_DATA_MODEL_CLASS'],
schema: generateGraphObjectSchema(),
});

expect(result).toEqual({
message: expect.any(Function),
pass: false,
});

expect(result.message()).toEqual(
`Error loading schemas for class (err=Invalid _class passed in schema for "toMatchGraphObjectSchema" (_class=#INVALID_DATA_MODEL_CLASS))`,
);
});
});
1 change: 1 addition & 0 deletions packages/integration-sdk-testing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './context';
export * from './logger';
export * from './recording';
export * from './jobState';
export * from './jest';
188 changes: 188 additions & 0 deletions packages/integration-sdk-testing/src/jest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import * as dataModel from '@jupiterone/data-model';
import * as deepmerge from 'deepmerge';
import { Entity } from '@jupiterone/integration-sdk-core';

function createGraphObjectSchemaValidationError<T>(
ajv: typeof dataModel.IntegrationSchema,
data: T,
index: number,
) {
const serializedData = JSON.stringify(data, null, 2);
const serializedErrors = JSON.stringify(ajv.errors, null, 2);

return {
message: () =>
`Error validating graph object against schema (data=${serializedData}, errors=${serializedErrors}, index=${index})`,
pass: false,
};
}

function collectSchemasFromRef(
dataModelIntegrationSchema: typeof dataModel.IntegrationSchema,
classSchemaRef: string,
): GraphObjectSchema[] {
const dataModelClassSchema = dataModelIntegrationSchema.getSchema(
classSchemaRef,
);

if (!dataModelClassSchema || !dataModelClassSchema.schema) {
throw new Error(
`Invalid _class passed in schema for "toMatchGraphObjectSchema" (_class=${classSchemaRef})`,
);
}

const dataModelValidationSchema = dataModelClassSchema.schema as dataModel.IntegrationEntitySchema;
let schemas: GraphObjectSchema[] = [dataModelValidationSchema];

if (!dataModelValidationSchema.allOf) {
return schemas;
}

for (const allOfProps of dataModelValidationSchema.allOf) {
const refProp = allOfProps.$ref;
if (!refProp) {
continue;
}

schemas = schemas.concat(
collectSchemasFromRef(dataModelIntegrationSchema, refProp),
);
}

return schemas;
}

function generateEntitySchemaFromDataModelSchemas(
schemas: GraphObjectSchema[],
) {
const newSchemas: GraphObjectSchema[] = [];

for (let schema of schemas) {
// Remove schema identifying properties
schema = {
...schema,
$schema: undefined,
$id: undefined,
description: undefined,
};

if (!schema.allOf) {
newSchemas.push(schema);
continue;
}

let newSchemaProperties = {};

// Flatten so `properties` are at the top level
for (const allOfProp of schema.allOf) {
if (allOfProp.$ref) {
continue;
}

// Merge the internal properties without $ref's
newSchemaProperties = deepmerge.all([newSchemaProperties, allOfProp]);
}

schema = deepmerge.all([
{
...schema,
allOf: undefined,
},
newSchemaProperties,
]);

newSchemas.push(schema);
}

return deepmerge.all(newSchemas);
}

function graphObjectClassToSchemaRef(_class: string) {
return `#${_class}`;
}

export interface GraphObjectSchema extends dataModel.IntegrationEntitySchema {
$schema?: string;
$id?: string;
description?: string;
additionalProperties?: boolean;
}

export interface ToMatchGraphObjectSchemaParams {
/**
* The JupiterOne hierarchy class or classes from the data model that will be used to generate a new schema to be validated against. See: https://github.com/JupiterOne/data-model/tree/master/src/schemas
*/
_class: string | string[];
/**
* The schema that should be used to validate the input data against
*/
schema: GraphObjectSchema;
}

export function toMatchGraphObjectSchema<T extends Entity>(
/**
* The data received from the test assertion. (e.g. expect(DATA_HERE).toMatchGraphObjectSchema(...)
*/
received: T | T[],
{ _class, schema }: ToMatchGraphObjectSchemaParams,
) {
// Copy this so that we do not interfere with globals.
// NOTE: The data-model should actuall expose a function for generating
// a new object of the `IntegrationSchema`.
const dataModelIntegrationSchema = dataModel.IntegrationSchema;
_class = Array.isArray(_class) ? _class : [_class];

let schemas: GraphObjectSchema[] = [];

for (const classInst of _class) {
try {
schemas = schemas.concat(
collectSchemasFromRef(
dataModelIntegrationSchema,
graphObjectClassToSchemaRef(classInst),
),
);
} catch (err) {
return {
message: () => `Error loading schemas for class (err=${err.message})`,
pass: false,
};
}
}

const newEntitySchema = generateEntitySchemaFromDataModelSchemas([
// Merging should have the highest-level schemas at the end of the array
// so that they can override the parent classes
...schemas.reverse(),
{
...schema,
properties: {
...schema.properties,
_class: {
const: _class,
},
},
},
]);

received = Array.isArray(received) ? received : [received];

for (let i = 0; i < received.length; i++) {
const data = received[i];

if (dataModelIntegrationSchema.validate(newEntitySchema, data)) {
continue;
}

return createGraphObjectSchemaValidationError(
dataModelIntegrationSchema,
data,
i,
);
}

return {
message: () => 'Success!',
pass: true,
};
}

0 comments on commit fcab7e6

Please sign in to comment.