You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
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 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 plugins = require('../src/plugins');
|
|
const flags = require('../src/flags');
|
|
const messaging = require('../src/messaging');
|
|
const utils = require('../src/utils');
|
|
|
|
describe('Read 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 = {
|
|
get: {},
|
|
post: {},
|
|
put: {},
|
|
delete: {
|
|
'/users/{uid}/tokens/{token}': [
|
|
{
|
|
in: 'path',
|
|
name: 'uid',
|
|
example: 1,
|
|
},
|
|
{
|
|
in: 'path',
|
|
name: 'token',
|
|
example: utils.generateUUID(),
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
async function dummySearchHook(data) {
|
|
return [1];
|
|
}
|
|
|
|
after(async function () {
|
|
plugins.unregisterHook('core', 'filter:search.query', dummySearchHook);
|
|
});
|
|
|
|
async function setupData() {
|
|
if (setup) {
|
|
return;
|
|
}
|
|
|
|
// Create sample users
|
|
const adminUid = await user.create({ username: 'admin', password: '123456', email: '[email protected]' });
|
|
const unprivUid = await user.create({ username: 'unpriv', password: '123456', email: '[email protected]' });
|
|
for (let x = 0; x < 3; x++) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await user.create({ username: 'deleteme', password: '123456' }); // for testing of user deletion routes (uids 4-6)
|
|
}
|
|
await groups.join('administrators', adminUid);
|
|
|
|
// Create sample group
|
|
await groups.create({
|
|
name: 'Test Group',
|
|
});
|
|
|
|
await meta.settings.set('core.api', {
|
|
tokens: [{
|
|
token: mocks.delete['/users/{uid}/tokens/{token}'][1].example,
|
|
uid: 1,
|
|
description: 'for testing of token deletion rotue',
|
|
timestamp: Date.now(),
|
|
}],
|
|
});
|
|
|
|
// Create a category
|
|
const testCategory = await categories.create({ name: 'test' });
|
|
|
|
// Post a new topic
|
|
const testTopic = 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',
|
|
});
|
|
|
|
// Create a sample flag
|
|
await flags.create('post', 1, unprivUid, 'sample reasons', Date.now());
|
|
|
|
// Create a new chat room
|
|
await messaging.newRoom(1, [2]);
|
|
|
|
// Create an empty file to test DELETE /files
|
|
fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.txt'), 'w'));
|
|
|
|
const socketUser = require('../src/socket.io/user');
|
|
// export data for admin user
|
|
await socketUser.exportProfile({ uid: adminUid }, { uid: adminUid });
|
|
await socketUser.exportPosts({ uid: adminUid }, { uid: adminUid });
|
|
await socketUser.exportUploads({ uid: adminUid }, { uid: adminUid });
|
|
// wait for export child process to complete
|
|
await wait(5000);
|
|
|
|
// Attach a search hook so /api/search is enabled
|
|
plugins.registerHook('core', {
|
|
hook: 'filter:search.query',
|
|
method: dummySearchHook,
|
|
});
|
|
|
|
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);
|
|
|
|
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
|
|
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) => {
|
|
if (api.info.title === 'NodeBB Read API' && _method !== 'get') {
|
|
return;
|
|
}
|
|
|
|
it('should have examples when parameters are present', () => {
|
|
method = _method;
|
|
let parameters = context[method].parameters;
|
|
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('may contain a request body with application/json type if POST/PUT/DELETE', () => {
|
|
if (['post', 'put', 'delete'].includes(method) && context[method].hasOwnProperty('requestBody')) {
|
|
assert(context[method].requestBody);
|
|
assert(context[method].requestBody.content);
|
|
assert(context[method].requestBody.content['application/json']);
|
|
assert(context[method].requestBody.content['application/json'].schema);
|
|
assert(context[method].requestBody.content['application/json'].schema.properties);
|
|
}
|
|
});
|
|
|
|
it('should resolve with a 200 when called', async () => {
|
|
await setupData();
|
|
|
|
if (csrfToken) {
|
|
headers['x-csrf-token'] = csrfToken;
|
|
}
|
|
|
|
let body = {};
|
|
if (context[method].hasOwnProperty('requestBody')) {
|
|
body = buildBody(context[method].requestBody.content['application/json'].schema.properties);
|
|
}
|
|
|
|
try {
|
|
// console.log(`calling ${method} ${url} with`, body);
|
|
response = await request(url, {
|
|
method: method,
|
|
jar: !unauthenticatedRoutes.includes(path) ? jar : undefined,
|
|
json: true,
|
|
headers: headers,
|
|
qs: qs,
|
|
body: body,
|
|
});
|
|
} catch (e) {
|
|
assert(!e, `${method.toUpperCase()} ${path} resolved with ${e.message}`);
|
|
}
|
|
});
|
|
|
|
// Recursively iterate through schema properties, comparing type
|
|
it('response should match schema definition', () => {
|
|
const has200 = context[method].responses['200'];
|
|
if (!has200) {
|
|
return;
|
|
}
|
|
|
|
const hasJSON = has200.content && has200.content['application/json'];
|
|
if (hasJSON) {
|
|
schema = context[method].responses['200'].content['application/json'].schema;
|
|
compare(schema, response, method.toUpperCase(), path, 'root');
|
|
}
|
|
|
|
// TODO someday: text/csv, binary file type checking?
|
|
});
|
|
|
|
it('should successfully re-login if needed', async () => {
|
|
const reloginPaths = ['/users/{uid}/password'];
|
|
if (method === 'put' && reloginPaths.includes(path)) {
|
|
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;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
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');
|
|
|
|
if (schema.allOf) {
|
|
schema = schema.allOf.reduce((memo, obj) => {
|
|
required = required.concat(obj.required ? obj.required : Object.keys(obj.properties));
|
|
memo = { ...memo, ...obj.properties };
|
|
return memo;
|
|
}, {});
|
|
} 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, '"' + 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)) {
|
|
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 + ')');
|
|
});
|
|
}
|
|
});
|