-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fixes #263 - Introduce custom Jest test matcher for matching collecte…
…d entities against a well-defined schema
- Loading branch information
Austin Kelleher
committed
Aug 2, 2020
1 parent
c8ef69a
commit fcab7e6
Showing
3 changed files
with
360 additions
and
0 deletions.
There are no files selected for viewing
171 changes: 171 additions & 0 deletions
171
packages/integration-sdk-testing/src/__tests__/jest.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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))`, | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} |