Compose-ready workspace: packages/zappier (rate card, portal, Stripe), packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier (CLI app), vendor/zapier-platform, and research/zapier vendor corpus. Gate 0 structure checks pass. Product code and research are not yet wired.
412 lines
12 KiB
JavaScript
412 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const should = require('should');
|
|
const schema = require('../schema');
|
|
|
|
const testUtils = require('./utils');
|
|
|
|
const appDefinition = require('../examples/definition.json');
|
|
|
|
const copy = (o) => JSON.parse(JSON.stringify(o));
|
|
|
|
const NUM_SCHEMAS = 65; // changes regularly as we expand
|
|
|
|
describe('app', () => {
|
|
describe('validation', () => {
|
|
it('should be a valid app', () => {
|
|
const results = schema.validateAppDefinition(appDefinition);
|
|
results.errors.should.eql([]);
|
|
});
|
|
|
|
it('should invalidate deep errors', () => {
|
|
const appCopy = copy(appDefinition);
|
|
delete appCopy.version;
|
|
delete appCopy.triggers.contact_by_tag.noun;
|
|
delete appCopy.triggers.contact_by_tag.display.label;
|
|
appCopy.triggers.contact_by_tag.operation.inputFields[0].type = 'loltype';
|
|
const results = schema.validateAppDefinition(appCopy);
|
|
results.errors.length.should.eql(4);
|
|
});
|
|
|
|
it('should invalidate a badly named trigger', () => {
|
|
const appCopy = copy(appDefinition);
|
|
appCopy.triggers['3contact_by_tag'] = appCopy.triggers.contact_by_tag;
|
|
delete appCopy.triggers.contact_by_tag;
|
|
const results = schema.validateAppDefinition(appCopy);
|
|
results.errors[0].stack.should.eql(
|
|
'instance.triggers is not allowed to have the additional property "3contact_by_tag"',
|
|
);
|
|
results.errors.length.should.eql(2); // additional property error + top-level key doesn't match trigger key
|
|
});
|
|
|
|
it('should invalidate a badly named create', () => {
|
|
const appCopy = copy(appDefinition);
|
|
appCopy.creates['3contact_by_tag'] = appCopy.creates.tag_create;
|
|
delete appCopy.creates.tag_create;
|
|
const results = schema.validateAppDefinition(appCopy);
|
|
results.errors[0].stack.should.eql(
|
|
'instance.creates is not allowed to have the additional property "3contact_by_tag"',
|
|
);
|
|
results.errors.length.should.eql(2); // additional property error + top-level key doesn't match create key
|
|
});
|
|
|
|
it('should invalidate a create with a bad key', () => {
|
|
const appCopy = copy(appDefinition);
|
|
appCopy.creates.tag_create.key = 'tag:create';
|
|
const results = schema.validateAppDefinition(appCopy);
|
|
results.errors[0].stack.should.eql(
|
|
'instance.creates.tag_create.key does not match pattern "^[a-zA-Z]+[a-zA-Z0-9_]*$"',
|
|
);
|
|
results.errors.length.should.eql(2); // invalid name and top-level key doesn't match create key
|
|
});
|
|
|
|
it('should run and pass functional constraints', function () {
|
|
const definition = {
|
|
version: '1.0.0',
|
|
platformVersion: '1.0.0',
|
|
searches: {
|
|
fooSearch: {
|
|
key: 'fooSearch',
|
|
noun: 'Foo',
|
|
display: {
|
|
label: 'Find Foo',
|
|
description: 'Find a foo...',
|
|
},
|
|
operation: {
|
|
perform: '$func$2$f$',
|
|
sample: { id: 1 },
|
|
},
|
|
},
|
|
},
|
|
creates: {
|
|
fooCreate: {
|
|
key: 'fooCreate',
|
|
noun: 'Foo',
|
|
display: {
|
|
label: 'Create Foo',
|
|
description: 'Creates a...',
|
|
},
|
|
operation: {
|
|
perform: '$func$2$f$',
|
|
sample: { id: 1 },
|
|
},
|
|
},
|
|
},
|
|
searchOrCreates: {
|
|
fooSearchOrCreate: {
|
|
key: 'fooSearch',
|
|
display: {
|
|
label: 'Find or Create a...',
|
|
description: 'Something Something',
|
|
},
|
|
search: 'fooSearch',
|
|
create: 'fooCreate',
|
|
},
|
|
},
|
|
};
|
|
const results = schema.validateAppDefinition(definition);
|
|
results.errors.should.have.length(0);
|
|
});
|
|
|
|
it('should run functional constraints with errors', function () {
|
|
const definition = {
|
|
version: '1.0.0',
|
|
platformVersion: '1.0.0',
|
|
searches: {
|
|
fooSearch: {
|
|
key: 'fooSearch',
|
|
noun: 'Foo',
|
|
display: {
|
|
label: 'Find Foo',
|
|
description: 'Find a foo...',
|
|
},
|
|
operation: {
|
|
perform: '$func$2$f$',
|
|
sample: { id: 1 },
|
|
},
|
|
},
|
|
},
|
|
creates: {
|
|
fooCreate: {
|
|
key: 'fooCreate',
|
|
noun: 'Foo',
|
|
display: {
|
|
label: 'Create Foo',
|
|
description: 'Creates a...',
|
|
},
|
|
operation: {
|
|
perform: '$func$2$f$',
|
|
sample: { id: 1 },
|
|
},
|
|
},
|
|
},
|
|
searchOrCreates: {
|
|
fooSearchOrCreate: {
|
|
key: 'fooSearchOrCreate',
|
|
display: {
|
|
label: 'Find or Create a...',
|
|
description: 'Something Something',
|
|
},
|
|
search: 'fooBad',
|
|
create: 'fooBad',
|
|
},
|
|
},
|
|
};
|
|
const results = schema.validateAppDefinition(definition);
|
|
results.errors.should.have.length(3);
|
|
results.errors[0].stack.should.eql(
|
|
'instance.searchOrCreates.fooSearchOrCreate.key must match a "key" from a search (options: fooSearch)',
|
|
);
|
|
results.errors[1].stack.should.eql(
|
|
'instance.searchOrCreates.fooSearchOrCreate.search must match a "key" from a search (options: fooSearch)',
|
|
);
|
|
results.errors[2].stack.should.eql(
|
|
'instance.searchOrCreates.fooSearchOrCreate.create must match a "key" from a create (options: fooCreate)',
|
|
);
|
|
});
|
|
|
|
it('should validate inputFormat', () => {
|
|
const appCopy = copy(appDefinition);
|
|
appCopy.authentication = {
|
|
type: 'custom',
|
|
test: {
|
|
url: 'https://example.com',
|
|
},
|
|
fields: [
|
|
{
|
|
key: 'subdomain',
|
|
type: 'string',
|
|
required: true,
|
|
inputFormat: 'https://{{input}}.example.com',
|
|
},
|
|
],
|
|
};
|
|
const results = schema.validateAppDefinition(appCopy);
|
|
// this line ensures we're getting a ValidatorResult class, not just an object that looks like one
|
|
should(results.valid).eql(true);
|
|
results.errors.should.eql([]);
|
|
});
|
|
|
|
it('should invalidate illegal inputFormat', () => {
|
|
const appCopy = copy(appDefinition);
|
|
appCopy.authentication = {
|
|
type: 'custom',
|
|
test: {
|
|
url: 'https://example.com',
|
|
},
|
|
fields: [
|
|
{
|
|
key: 'subdomain',
|
|
type: 'string',
|
|
required: true,
|
|
inputFormat: 'https://{{input}.example.com',
|
|
},
|
|
],
|
|
};
|
|
const results = schema.validateAppDefinition(appCopy);
|
|
results.errors.length.should.eql(1);
|
|
should(results.valid).eql(false);
|
|
|
|
const error = results.errors[0];
|
|
error.name.should.eql('pattern');
|
|
error.instance.should.eql('https://{{input}.example.com');
|
|
});
|
|
|
|
it('should validate legacy properties', () => {
|
|
const appCopy = copy(appDefinition);
|
|
appCopy.legacy = {
|
|
subscribeUrl: 'https://example.com',
|
|
triggers: {
|
|
contact: {
|
|
url: 'https://example.com',
|
|
},
|
|
},
|
|
};
|
|
const results = schema.validateAppDefinition(appCopy);
|
|
results.errors.should.eql([]);
|
|
});
|
|
|
|
it('should validate safe/unsafe auth fields', () => {
|
|
const appCopy = copy(appDefinition);
|
|
|
|
// Set up a "custom" auth that has two fields:
|
|
// - "username" which is safe
|
|
// - "password" which is not safe
|
|
appCopy.authentication = {
|
|
type: 'custom',
|
|
test: {
|
|
url: 'https://example.com',
|
|
},
|
|
fields: [
|
|
{
|
|
key: 'username',
|
|
type: 'string',
|
|
isNoSecret: true, // allowed
|
|
required: true,
|
|
},
|
|
{
|
|
key: 'password',
|
|
type: 'password',
|
|
isNoSecret: false, // password is not safe
|
|
required: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
const results = schema.validateAppDefinition(appCopy);
|
|
|
|
// Expect zero errors
|
|
results.errors.should.have.length(0);
|
|
should(results.valid).eql(true);
|
|
});
|
|
|
|
it('should invalidate a "safe" password field', () => {
|
|
const appCopy = copy(appDefinition);
|
|
|
|
// Same "custom" auth but now marking "password" as isNoSecret=true
|
|
appCopy.authentication = {
|
|
type: 'custom',
|
|
test: {
|
|
url: 'https://example.com',
|
|
},
|
|
fields: [
|
|
{
|
|
key: 'password',
|
|
type: 'string',
|
|
isNoSecret: true, // invalid: password cannot be safe
|
|
required: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
const results = schema.validateAppDefinition(appCopy);
|
|
|
|
// Expect at least one error because "password" can't have isNoSecret = true
|
|
results.errors.should.have.length(1);
|
|
should(results.valid).eql(false);
|
|
|
|
const [error] = results.errors;
|
|
error.name.should.equal('sensitive');
|
|
error.stack.should.eql(
|
|
'instance.authentication.fields[0] cannot set isNoSecret as true for the sensitive key "password".',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('export', () => {
|
|
it('should export the full schema', () => {
|
|
const exportedSchema = schema.exportSchema();
|
|
Object.keys(exportedSchema.schemas).length.should.eql(NUM_SCHEMAS);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('dynamic choices disambiguation', () => {
|
|
const FieldChoicesSchema = require('../lib/schemas/FieldChoicesSchema');
|
|
const FieldDynamicChoicesSchema = require('../lib/schemas/FieldDynamicChoicesSchema');
|
|
|
|
it('FieldChoicesSchema should reject objects with a perform key', () => {
|
|
const { errors } = FieldChoicesSchema.validate({ perform: '$func$0$f$' });
|
|
errors.should.not.have.length(0);
|
|
});
|
|
|
|
it('FieldDynamicChoicesSchema should accept objects with a perform key', () => {
|
|
const { errors } = FieldDynamicChoicesSchema.validate({
|
|
perform: '$func$0$f$',
|
|
});
|
|
errors.should.have.length(0);
|
|
});
|
|
|
|
it('FieldChoicesSchema should still accept static object choices', () => {
|
|
const { errors } = FieldChoicesSchema.validate({
|
|
high: 'High',
|
|
medium: 'Medium',
|
|
low: 'Low',
|
|
});
|
|
errors.should.have.length(0);
|
|
});
|
|
|
|
it('FieldChoicesSchema should still accept static array choices', () => {
|
|
const { errors } = FieldChoicesSchema.validate(['open', 'closed']);
|
|
errors.should.have.length(0);
|
|
});
|
|
|
|
it('should validate an app with dynamic choices on an input field', () => {
|
|
const definition = {
|
|
version: '1.0.0',
|
|
platformVersion: '1.0.0',
|
|
triggers: {
|
|
newThing: {
|
|
key: 'newThing',
|
|
noun: 'Thing',
|
|
display: {
|
|
label: 'New Thing',
|
|
description: 'Triggers on new things.',
|
|
},
|
|
operation: {
|
|
type: 'polling',
|
|
perform: '$func$0$f$',
|
|
sample: { id: 1 },
|
|
inputFields: [
|
|
{
|
|
key: 'org',
|
|
label: 'Organization',
|
|
helpText: 'Pick one.',
|
|
type: 'string',
|
|
required: true,
|
|
choices: { perform: '$func$1$f$' },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const results = schema.validateAppDefinition(definition);
|
|
results.errors.should.have.length(0);
|
|
});
|
|
|
|
it('should validate an app with both static and dynamic choices', () => {
|
|
const definition = {
|
|
version: '1.0.0',
|
|
platformVersion: '1.0.0',
|
|
triggers: {
|
|
newThing: {
|
|
key: 'newThing',
|
|
noun: 'Thing',
|
|
display: {
|
|
label: 'New Thing',
|
|
description: 'Triggers on new things.',
|
|
},
|
|
operation: {
|
|
type: 'polling',
|
|
perform: '$func$0$f$',
|
|
sample: { id: 1 },
|
|
inputFields: [
|
|
{
|
|
key: 'org',
|
|
choices: { perform: '$func$1$f$' },
|
|
},
|
|
{
|
|
key: 'priority',
|
|
choices: { high: 'High', low: 'Low' },
|
|
},
|
|
{
|
|
key: 'status',
|
|
choices: ['open', 'closed'],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const results = schema.validateAppDefinition(definition);
|
|
results.errors.should.have.length(0);
|
|
});
|
|
});
|
|
|
|
describe('auto test', () => {
|
|
const _exportedSchema = schema.exportSchema();
|
|
Object.keys(_exportedSchema.schemas).map((id) =>
|
|
testUtils.testInlineSchemaExamples(id),
|
|
);
|
|
});
|