mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-27 09:06:15 +01:00
* refactor: how admins change emails ability for admins to change emails from acp ability for admins to change passwords from acp only users themselves can use /user/<slug>/edit/email group actions in manage users dropdown admins can use the same modal from profile page instead of interstitial to update email add missing checks to addEmail, if email take throw error add targetUid to email change event * test: bunch of baloney * test: remove old test
685 lines
23 KiB
JavaScript
685 lines
23 KiB
JavaScript
'use strict';
|
|
|
|
const _ = require('lodash');
|
|
const assert = require('assert');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const SwaggerParser = require('@apidevtools/swagger-parser');
|
|
const request = require('request-promise-native');
|
|
const nconf = require('nconf');
|
|
const jwt = require('jsonwebtoken');
|
|
const util = require('util');
|
|
|
|
const wait = util.promisify(setTimeout);
|
|
|
|
const db = require('./mocks/databasemock');
|
|
const helpers = require('./helpers');
|
|
const meta = require('../src/meta');
|
|
const user = require('../src/user');
|
|
const groups = require('../src/groups');
|
|
const categories = require('../src/categories');
|
|
const topics = require('../src/topics');
|
|
const posts = require('../src/posts');
|
|
const plugins = require('../src/plugins');
|
|
const flags = require('../src/flags');
|
|
const messaging = require('../src/messaging');
|
|
const utils = require('../src/utils');
|
|
const api = require('../src/api');
|
|
|
|
describe('API', async () => {
|
|
let readApi = false;
|
|
let writeApi = false;
|
|
const readApiPath = path.resolve(__dirname, '../public/openapi/read.yaml');
|
|
const writeApiPath = path.resolve(__dirname, '../public/openapi/write.yaml');
|
|
let jar;
|
|
let csrfToken;
|
|
let setup = false;
|
|
const unauthenticatedRoutes = ['/api/login', '/api/register']; // Everything else will be called with the admin user
|
|
|
|
const mocks = {
|
|
head: {},
|
|
get: {
|
|
'/api/email/unsubscribe/{token}': [
|
|
{
|
|
in: 'path',
|
|
name: 'token',
|
|
example: (() => jwt.sign({
|
|
template: 'digest',
|
|
uid: 1,
|
|
}, nconf.get('secret')))(),
|
|
},
|
|
],
|
|
'/api/confirm/{code}': [
|
|
{
|
|
in: 'path',
|
|
name: 'code',
|
|
example: '', // to be defined later...
|
|
},
|
|
],
|
|
'/admin/tokens/{token}': [
|
|
{
|
|
in: 'path',
|
|
name: 'token',
|
|
example: '', // to be defined later...
|
|
},
|
|
],
|
|
},
|
|
post: {
|
|
'/admin/tokens/{token}/roll': [
|
|
{
|
|
in: 'path',
|
|
name: 'token',
|
|
example: '', // to be defined later...
|
|
},
|
|
],
|
|
},
|
|
put: {
|
|
'/groups/{slug}/pending/{uid}': [
|
|
{
|
|
in: 'path',
|
|
name: 'slug',
|
|
example: 'private-group',
|
|
},
|
|
{
|
|
in: 'path',
|
|
name: 'uid',
|
|
example: '', // to be defined later...
|
|
},
|
|
],
|
|
'/admin/tokens/{token}': [
|
|
{
|
|
in: 'path',
|
|
name: 'token',
|
|
example: '', // to be defined later...
|
|
},
|
|
],
|
|
},
|
|
patch: {},
|
|
delete: {
|
|
'/users/{uid}/tokens/{token}': [
|
|
{
|
|
in: 'path',
|
|
name: 'uid',
|
|
example: 1,
|
|
},
|
|
{
|
|
in: 'path',
|
|
name: 'token',
|
|
example: utils.generateUUID(),
|
|
},
|
|
],
|
|
'/users/{uid}/sessions/{uuid}': [
|
|
{
|
|
in: 'path',
|
|
name: 'uid',
|
|
example: 1,
|
|
},
|
|
{
|
|
in: 'path',
|
|
name: 'uuid',
|
|
example: '', // to be defined below...
|
|
},
|
|
],
|
|
'/posts/{pid}/diffs/{timestamp}': [
|
|
{
|
|
in: 'path',
|
|
name: 'pid',
|
|
example: '', // to be defined below...
|
|
},
|
|
{
|
|
in: 'path',
|
|
name: 'timestamp',
|
|
example: '', // to be defined below...
|
|
},
|
|
],
|
|
'/groups/{slug}/pending/{uid}': [
|
|
{
|
|
in: 'path',
|
|
name: 'slug',
|
|
example: 'private-group',
|
|
},
|
|
{
|
|
in: 'path',
|
|
name: 'uid',
|
|
example: '', // to be defined later...
|
|
},
|
|
],
|
|
'/groups/{slug}/invites/{uid}': [
|
|
{
|
|
in: 'path',
|
|
name: 'slug',
|
|
example: 'invitations-only',
|
|
},
|
|
{
|
|
in: 'path',
|
|
name: 'uid',
|
|
example: '', // to be defined later...
|
|
},
|
|
],
|
|
'/admin/tokens/{token}': [
|
|
{
|
|
in: 'path',
|
|
name: 'token',
|
|
example: '', // to be defined later...
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
async function dummySearchHook(data) {
|
|
return [1];
|
|
}
|
|
async function dummyEmailerHook(data) {
|
|
// pretend to handle sending emails
|
|
}
|
|
|
|
after(async () => {
|
|
plugins.hooks.unregister('core', 'filter:search.query', dummySearchHook);
|
|
plugins.hooks.unregister('emailer-test', 'filter:email.send');
|
|
});
|
|
|
|
async function setupData() {
|
|
if (setup) {
|
|
return;
|
|
}
|
|
|
|
// Create sample users
|
|
const adminUid = await user.create({ username: 'admin', password: '123456' });
|
|
const unprivUid = await user.create({ username: 'unpriv', password: '123456' });
|
|
const emailConfirmationUid = await user.create({ username: 'emailConf', email: 'emailConf@example.org' });
|
|
await user.setUserField(adminUid, 'email', 'test@example.org');
|
|
await user.setUserField(unprivUid, 'email', 'unpriv@example.org');
|
|
await user.email.confirmByUid(adminUid);
|
|
await user.email.confirmByUid(unprivUid);
|
|
mocks.get['/api/confirm/{code}'][0].example = await db.get(`confirm:byUid:${emailConfirmationUid}`);
|
|
|
|
for (let x = 0; x < 4; x++) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await user.create({ username: 'deleteme', password: '123456' }); // for testing of DELETE /users (uids 5, 6) and DELETE /user/:uid/account (uid 7)
|
|
}
|
|
await groups.join('administrators', adminUid);
|
|
|
|
// Create api token for testing read/updating/deletion
|
|
const token = await api.utils.tokens.generate({ uid: adminUid });
|
|
mocks.get['/admin/tokens/{token}'][0].example = token;
|
|
mocks.put['/admin/tokens/{token}'][0].example = token;
|
|
mocks.delete['/admin/tokens/{token}'][0].example = token;
|
|
|
|
// Create another token for testing rolling
|
|
const token2 = await api.utils.tokens.generate({ uid: adminUid });
|
|
mocks.post['/admin/tokens/{token}/roll'][0].example = token2;
|
|
|
|
// Create sample group
|
|
await groups.create({
|
|
name: 'Test Group',
|
|
});
|
|
|
|
// Create private groups for pending/invitations
|
|
const [pending1, pending2, inviteUid] = await Promise.all([
|
|
await user.create({ username: utils.generateUUID().slice(0, 8) }),
|
|
await user.create({ username: utils.generateUUID().slice(0, 8) }),
|
|
await user.create({ username: utils.generateUUID().slice(0, 8) }),
|
|
]);
|
|
mocks.put['/groups/{slug}/pending/{uid}'][1].example = pending1;
|
|
mocks.delete['/groups/{slug}/pending/{uid}'][1].example = pending2;
|
|
mocks.delete['/groups/{slug}/invites/{uid}'][1].example = inviteUid;
|
|
await Promise.all(['private-group', 'invitations-only'].map(async (name) => {
|
|
await groups.create({ name, private: true });
|
|
}));
|
|
await groups.requestMembership('private-group', pending1);
|
|
await groups.requestMembership('private-group', pending2);
|
|
await groups.invite('invitations-only', inviteUid);
|
|
|
|
await meta.settings.set('core.api', {
|
|
tokens: [{
|
|
token: mocks.delete['/users/{uid}/tokens/{token}'][1].example,
|
|
uid: 1,
|
|
description: 'for testing of token deletion route',
|
|
timestamp: Date.now(),
|
|
}],
|
|
});
|
|
meta.config.allowTopicsThumbnail = 1;
|
|
meta.config.termsOfUse = 'I, for one, welcome our new test-driven overlords';
|
|
meta.config.chatMessageDelay = 0;
|
|
|
|
// Create a category
|
|
const testCategory = await categories.create({ name: 'test' });
|
|
|
|
// Post a new topic
|
|
await topics.post({
|
|
uid: adminUid,
|
|
cid: testCategory.cid,
|
|
title: 'Test Topic',
|
|
content: 'Test topic content',
|
|
});
|
|
const unprivTopic = await topics.post({
|
|
uid: unprivUid,
|
|
cid: testCategory.cid,
|
|
title: 'Test Topic 2',
|
|
content: 'Test topic 2 content',
|
|
});
|
|
await topics.post({
|
|
uid: unprivUid,
|
|
cid: testCategory.cid,
|
|
title: 'Test Topic 3',
|
|
content: 'Test topic 3 content',
|
|
});
|
|
|
|
// Create a post diff
|
|
await posts.edit({
|
|
uid: adminUid,
|
|
pid: unprivTopic.postData.pid,
|
|
content: 'Test topic 2 edited content',
|
|
req: {},
|
|
});
|
|
mocks.delete['/posts/{pid}/diffs/{timestamp}'][0].example = unprivTopic.postData.pid;
|
|
mocks.delete['/posts/{pid}/diffs/{timestamp}'][1].example = (await posts.diffs.list(unprivTopic.postData.pid))[0];
|
|
|
|
// Create a sample flag
|
|
const { flagId } = await flags.create('post', 1, unprivUid, 'sample reasons', Date.now()); // deleted in DELETE /api/v3/flags/1
|
|
await flags.appendNote(flagId, 1, 'test note', 1626446956652);
|
|
await flags.create('post', 2, unprivUid, 'sample reasons', Date.now()); // for testing flag notes (since flag 1 deleted)
|
|
|
|
// Create a new chat room
|
|
await messaging.newRoom(1, { uids: [2] });
|
|
|
|
// Create an empty file to test DELETE /files and thumb deletion
|
|
fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.txt'), 'w'));
|
|
fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.png'), 'w'));
|
|
|
|
// Associate thumb with topic to test thumb reordering
|
|
await topics.thumbs.associate({
|
|
id: 2,
|
|
path: 'files/test.png',
|
|
});
|
|
|
|
const socketAdmin = require('../src/socket.io/admin');
|
|
await Promise.all(['profile', 'posts', 'uploads'].map(async type => api.users.generateExport({ uid: adminUid }, { uid: adminUid, type })));
|
|
await socketAdmin.user.exportUsersCSV({ uid: adminUid }, {});
|
|
// wait for export child processes to complete
|
|
await wait(5000);
|
|
|
|
// Attach a search hook so /api/search is enabled
|
|
plugins.hooks.register('core', {
|
|
hook: 'filter:search.query',
|
|
method: dummySearchHook,
|
|
});
|
|
// Attach an emailer hook so related requests do not error
|
|
plugins.hooks.register('emailer-test', {
|
|
hook: 'filter:email.send',
|
|
method: dummyEmailerHook,
|
|
});
|
|
|
|
// All tests run as admin user
|
|
({ jar } = await helpers.loginUser('admin', '123456'));
|
|
|
|
// Retrieve CSRF token using cookie, to test Write API
|
|
const config = await request({
|
|
url: `${nconf.get('url')}/api/config`,
|
|
json: true,
|
|
jar: jar,
|
|
});
|
|
csrfToken = config.csrf_token;
|
|
|
|
setup = true;
|
|
}
|
|
|
|
it('should pass OpenAPI v3 validation', async () => {
|
|
try {
|
|
await SwaggerParser.validate(readApiPath);
|
|
await SwaggerParser.validate(writeApiPath);
|
|
} catch (e) {
|
|
assert.ifError(e);
|
|
}
|
|
});
|
|
|
|
readApi = await SwaggerParser.dereference(readApiPath);
|
|
writeApi = await SwaggerParser.dereference(writeApiPath);
|
|
|
|
it('should grab all mounted routes and ensure a schema exists', async () => {
|
|
const webserver = require('../src/webserver');
|
|
const buildPaths = function (stack, prefix) {
|
|
const paths = stack.map((dispatch) => {
|
|
if (dispatch.route && dispatch.route.path && typeof dispatch.route.path === 'string') {
|
|
if (!prefix && !dispatch.route.path.startsWith('/api/')) {
|
|
return null;
|
|
}
|
|
|
|
if (prefix === nconf.get('relative_path')) {
|
|
prefix = '';
|
|
}
|
|
|
|
return {
|
|
method: Object.keys(dispatch.route.methods)[0],
|
|
path: (prefix || '') + dispatch.route.path,
|
|
};
|
|
} else if (dispatch.name === 'router') {
|
|
const prefix = dispatch.regexp.toString().replace('/^', '').replace('\\/?(?=\\/|$)/i', '').replace(/\\\//g, '/');
|
|
return buildPaths(dispatch.handle.stack, prefix);
|
|
}
|
|
|
|
// Drop any that aren't actual routes (middlewares, error handlers, etc.)
|
|
return null;
|
|
});
|
|
|
|
return _.flatten(paths);
|
|
};
|
|
|
|
let paths = buildPaths(webserver.app._router.stack).filter(Boolean).map((pathObj) => {
|
|
pathObj.path = pathObj.path.replace(/\/:([^\\/]+)/g, '/{$1}');
|
|
return pathObj;
|
|
});
|
|
const exclusionPrefixes = [
|
|
'/api/admin/plugins', '/api/compose', '/debug',
|
|
'/api/user/{userslug}/theme', // from persona
|
|
];
|
|
paths = paths.filter(path => path.method !== '_all' && !exclusionPrefixes.some(prefix => path.path.startsWith(prefix)));
|
|
|
|
|
|
// For each express path, query for existence in read and write api schemas
|
|
paths.forEach((pathObj) => {
|
|
describe(`${pathObj.method.toUpperCase()} ${pathObj.path}`, () => {
|
|
it('should be defined in schema docs', () => {
|
|
let schema = readApi;
|
|
if (pathObj.path.startsWith('/api/v3')) {
|
|
schema = writeApi;
|
|
pathObj.path = pathObj.path.replace('/api/v3', '');
|
|
}
|
|
|
|
// Don't check non-GET routes in Read API
|
|
if (schema === readApi && pathObj.method !== 'get') {
|
|
return;
|
|
}
|
|
|
|
const normalizedPath = pathObj.path.replace(/\/:([^\\/]+)/g, '/{$1}').replace(/\?/g, '');
|
|
assert(schema.paths.hasOwnProperty(normalizedPath), `${pathObj.path} is not defined in schema docs`);
|
|
assert(schema.paths[normalizedPath].hasOwnProperty(pathObj.method), `${pathObj.path} was found in schema docs, but ${pathObj.method.toUpperCase()} method is not defined`);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
generateTests(readApi, Object.keys(readApi.paths));
|
|
generateTests(writeApi, Object.keys(writeApi.paths), writeApi.servers[0].url);
|
|
|
|
function generateTests(api, paths, prefix) {
|
|
// Iterate through all documented paths, make a call to it,
|
|
// and compare the result body with what is defined in the spec
|
|
const pathLib = path; // for calling path module from inside this forEach
|
|
paths.forEach((path) => {
|
|
const context = api.paths[path];
|
|
let schema;
|
|
let response;
|
|
let url;
|
|
let method;
|
|
const headers = {};
|
|
const qs = {};
|
|
|
|
Object.keys(context).forEach((_method) => {
|
|
// Only test GET routes in the Read API
|
|
if (api.info.title === 'NodeBB Read API' && _method !== 'get') {
|
|
return;
|
|
}
|
|
|
|
it('should have each path parameter defined in its context', () => {
|
|
method = _method;
|
|
if (!context[method].parameters) {
|
|
return;
|
|
}
|
|
|
|
const pathParams = (path.match(/{[\w\-_*]+}?/g) || []).map(match => match.slice(1, -1));
|
|
const schemaParams = context[method].parameters.map(param => (param.in === 'path' ? param.name : null)).filter(Boolean);
|
|
assert(pathParams.every(param => schemaParams.includes(param)), `${method.toUpperCase()} ${path} has path parameters specified but not defined`);
|
|
});
|
|
|
|
it('should have examples when parameters are present', () => {
|
|
let { parameters } = context[method];
|
|
let testPath = path;
|
|
|
|
if (parameters) {
|
|
// Use mock data if provided
|
|
parameters = mocks[method][path] || parameters;
|
|
|
|
parameters.forEach((param) => {
|
|
assert(param.example !== null && param.example !== undefined, `${method.toUpperCase()} ${path} has parameters without examples`);
|
|
|
|
switch (param.in) {
|
|
case 'path':
|
|
testPath = testPath.replace(`{${param.name}}`, param.example);
|
|
break;
|
|
case 'header':
|
|
headers[param.name] = param.example;
|
|
break;
|
|
case 'query':
|
|
qs[param.name] = param.example;
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
url = nconf.get('url') + (prefix || '') + testPath;
|
|
});
|
|
|
|
it('should contain a valid request body (if present) with application/json or multipart/form-data type if POST/PUT/DELETE', () => {
|
|
if (['post', 'put', 'delete'].includes(method) && context[method].hasOwnProperty('requestBody')) {
|
|
const failMessage = `${method.toUpperCase()} ${path} has a malformed request body`;
|
|
assert(context[method].requestBody, failMessage);
|
|
assert(context[method].requestBody.content, failMessage);
|
|
|
|
if (context[method].requestBody.content.hasOwnProperty('application/json')) {
|
|
assert(context[method].requestBody.content['application/json'], failMessage);
|
|
assert(context[method].requestBody.content['application/json'].schema, failMessage);
|
|
assert(context[method].requestBody.content['application/json'].schema.properties, failMessage);
|
|
} else if (context[method].requestBody.content.hasOwnProperty('multipart/form-data')) {
|
|
assert(context[method].requestBody.content['multipart/form-data'], failMessage);
|
|
assert(context[method].requestBody.content['multipart/form-data'].schema, failMessage);
|
|
assert(context[method].requestBody.content['multipart/form-data'].schema.properties, failMessage);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should not error out when called', async () => {
|
|
await setupData();
|
|
|
|
if (csrfToken) {
|
|
headers['x-csrf-token'] = csrfToken;
|
|
}
|
|
|
|
let body = {};
|
|
let type = 'json';
|
|
if (
|
|
context[method].hasOwnProperty('requestBody') &&
|
|
context[method].requestBody.required !== false &&
|
|
context[method].requestBody.content['application/json']) {
|
|
body = buildBody(context[method].requestBody.content['application/json'].schema.properties);
|
|
} else if (context[method].hasOwnProperty('requestBody') && context[method].requestBody.content['multipart/form-data']) {
|
|
type = 'form';
|
|
}
|
|
|
|
try {
|
|
if (type === 'json') {
|
|
response = await request(url, {
|
|
method: method,
|
|
jar: !unauthenticatedRoutes.includes(path) ? jar : undefined,
|
|
json: true,
|
|
followRedirect: false, // all responses are significant (e.g. 302)
|
|
simple: false, // don't throw on non-200 (e.g. 302)
|
|
resolveWithFullResponse: true, // send full request back (to check statusCode)
|
|
headers: headers,
|
|
qs: qs,
|
|
body: body,
|
|
});
|
|
} else if (type === 'form') {
|
|
response = await new Promise((resolve, reject) => {
|
|
helpers.uploadFile(url, pathLib.join(__dirname, './files/test.png'), {}, jar, csrfToken, (err, res) => {
|
|
if (err) {
|
|
return reject(err);
|
|
}
|
|
resolve(res);
|
|
});
|
|
});
|
|
}
|
|
} catch (e) {
|
|
assert(!e, `${method.toUpperCase()} ${path} errored with: ${e.message}`);
|
|
}
|
|
});
|
|
|
|
it('response status code should match one of the schema defined responses', () => {
|
|
// HACK: allow HTTP 418 I am a teapot, for now 👇
|
|
assert(context[method].responses.hasOwnProperty('418') || Object.keys(context[method].responses).includes(String(response.statusCode)), `${method.toUpperCase()} ${path} sent back unexpected HTTP status code: ${response.statusCode}`);
|
|
});
|
|
|
|
// Recursively iterate through schema properties, comparing type
|
|
it('response body should match schema definition', () => {
|
|
const http302 = context[method].responses['302'];
|
|
if (http302 && response.statusCode === 302) {
|
|
// Compare headers instead
|
|
const expectedHeaders = Object.keys(http302.headers).reduce((memo, name) => {
|
|
const value = http302.headers[name].schema.example;
|
|
memo[name] = value.startsWith(nconf.get('relative_path')) ? value : nconf.get('relative_path') + value;
|
|
return memo;
|
|
}, {});
|
|
|
|
for (const header of Object.keys(expectedHeaders)) {
|
|
assert(response.headers[header.toLowerCase()]);
|
|
assert.strictEqual(response.headers[header.toLowerCase()], expectedHeaders[header]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (response.statusCode === 400 && context[method].responses['400']) {
|
|
// TODO: check 400 schema to response.body?
|
|
return;
|
|
}
|
|
|
|
const http200 = context[method].responses['200'];
|
|
if (!http200) {
|
|
return;
|
|
}
|
|
|
|
assert.strictEqual(response.statusCode, 200, `HTTP 200 expected (path: ${method} ${path}`);
|
|
|
|
const hasJSON = http200.content && http200.content['application/json'];
|
|
if (hasJSON) {
|
|
schema = context[method].responses['200'].content['application/json'].schema;
|
|
compare(schema, response.body, method.toUpperCase(), path, 'root');
|
|
}
|
|
|
|
// TODO someday: text/csv, binary file type checking?
|
|
});
|
|
|
|
it('should successfully re-login if needed', async () => {
|
|
const reloginPaths = ['GET /api/user/{userslug}/edit/email', 'PUT /users/{uid}/password', 'DELETE /users/{uid}/sessions/{uuid}'];
|
|
if (reloginPaths.includes(`${method.toUpperCase()} ${path}`)) {
|
|
({ jar } = await helpers.loginUser('admin', '123456'));
|
|
const sessionUUIDs = await db.getObject('uid:1:sessionUUID:sessionId');
|
|
mocks.delete['/users/{uid}/sessions/{uuid}'][1].example = Object.keys(sessionUUIDs).pop();
|
|
|
|
// Retrieve CSRF token using cookie, to test Write API
|
|
const config = await request({
|
|
url: `${nconf.get('url')}/api/config`,
|
|
json: true,
|
|
jar: jar,
|
|
});
|
|
csrfToken = config.csrf_token;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function buildBody(schema) {
|
|
return Object.keys(schema).reduce((memo, cur) => {
|
|
memo[cur] = schema[cur].example;
|
|
return memo;
|
|
}, {});
|
|
}
|
|
|
|
function compare(schema, response, method, path, context) {
|
|
let required = [];
|
|
const additionalProperties = schema.hasOwnProperty('additionalProperties');
|
|
|
|
function flattenAllOf(obj) {
|
|
return obj.reduce((memo, obj) => {
|
|
if (obj.allOf) {
|
|
obj = { properties: flattenAllOf(obj.allOf) };
|
|
} else {
|
|
try {
|
|
required = required.concat(obj.required ? obj.required : Object.keys(obj.properties));
|
|
} catch (e) {
|
|
assert.fail(`Syntax error re: allOf, perhaps you allOf'd an array? (path: ${method} ${path}, context: ${context})`);
|
|
}
|
|
}
|
|
|
|
return { ...memo, ...obj.properties };
|
|
}, {});
|
|
}
|
|
|
|
if (schema.allOf) {
|
|
schema = flattenAllOf(schema.allOf);
|
|
} else if (schema.properties) {
|
|
required = schema.required || Object.keys(schema.properties);
|
|
schema = schema.properties;
|
|
} else {
|
|
// If schema contains no properties, check passes
|
|
return;
|
|
}
|
|
|
|
// Compare the schema to the response
|
|
required.forEach((prop) => {
|
|
if (schema.hasOwnProperty(prop)) {
|
|
assert(response.hasOwnProperty(prop), `"${prop}" is a required property (path: ${method} ${path}, context: ${context})`);
|
|
|
|
// Don't proceed with type-check if the value could possibly be unset (nullable: true, in spec)
|
|
if (response[prop] === null && schema[prop].nullable === true) {
|
|
return;
|
|
}
|
|
|
|
// Therefore, if the value is actually null, that's a problem (nullable is probably missing)
|
|
assert(response[prop] !== null, `"${prop}" was null, but schema does not specify it to be a nullable property (path: ${method} ${path}, context: ${context})`);
|
|
|
|
switch (schema[prop].type) {
|
|
case 'string':
|
|
assert.strictEqual(typeof response[prop], 'string', `"${prop}" was expected to be a string, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
|
break;
|
|
case 'boolean':
|
|
assert.strictEqual(typeof response[prop], 'boolean', `"${prop}" was expected to be a boolean, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
|
break;
|
|
case 'object':
|
|
assert.strictEqual(typeof response[prop], 'object', `"${prop}" was expected to be an object, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
|
compare(schema[prop], response[prop], method, path, context ? [context, prop].join('.') : prop);
|
|
break;
|
|
case 'array':
|
|
assert.strictEqual(Array.isArray(response[prop]), true, `"${prop}" was expected to be an array, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
|
|
|
if (schema[prop].items) {
|
|
// Ensure the array items have a schema defined
|
|
assert(schema[prop].items.type || schema[prop].items.allOf || schema[prop].items.anyOf || schema[prop].items.oneOf, `"${prop}" is defined to be an array, but its items have no schema defined (path: ${method} ${path}, context: ${context})`);
|
|
|
|
// Compare types
|
|
if (schema[prop].items.type === 'object' || Array.isArray(schema[prop].items.allOf || schema[prop].items.anyOf || schema[prop].items.oneOf)) {
|
|
response[prop].forEach((res) => {
|
|
compare(schema[prop].items, res, method, path, context ? [context, prop].join('.') : prop);
|
|
});
|
|
} else if (response[prop].length) { // for now
|
|
response[prop].forEach((item) => {
|
|
assert.strictEqual(typeof item, schema[prop].items.type, `"${prop}" should have ${schema[prop].items.type} items, but found ${typeof items} instead (path: ${method} ${path}, context: ${context})`);
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Compare the response to the schema
|
|
Object.keys(response).forEach((prop) => {
|
|
if (additionalProperties) { // All bets are off
|
|
return;
|
|
}
|
|
|
|
assert(schema[prop], `"${prop}" was found in response, but is not defined in schema (path: ${method} ${path}, context: ${context})`);
|
|
});
|
|
}
|
|
});
|