'use strict'; const assert = require('assert'); const path = require('path'); const SwaggerParser = require('@apidevtools/swagger-parser'); const request = require('request-promise-native'); const nconf = require('nconf'); const db = require('./mocks/databasemock'); const helpers = require('./helpers'); 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'); describe('Read API', async () => { let readApi = false; const apiPath = path.resolve(__dirname, '../public/openapi/read.yaml'); let jar; let setup = false; const unauthenticatedRoutes = ['/api/login', '/api/register']; // Everything else will be called with the admin user 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: 'test@example.org' }); const unprivUid = await user.create({ username: 'unpriv', password: '123456', email: 'unpriv@example.org' }); await groups.join('administrators', adminUid); // 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', }); // Create a sample flag await flags.create('post', 1, unprivUid, 'sample reasons', Date.now()); // Create a new chat room await messaging.newRoom(1, [2]); // Attach a search hook so /api/search is enabled plugins.registerHook('core', { hook: 'filter:search.query', method: dummySearchHook, }); jar = await helpers.loginUser('admin', '123456'); setup = true; } it('should pass OpenAPI v3 validation', async () => { try { await SwaggerParser.validate(apiPath); } catch (e) { assert.ifError(e); } }); readApi = await SwaggerParser.dereference(apiPath); // Iterate through all documented paths, make a call to it, and compare the result body with what is defined in the spec const paths = Object.keys(readApi.paths); paths.forEach((path) => { let schema; let response; let url; const headers = {}; const qs = {}; function compare(schema, response, 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: ' + 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: ' + 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: ' + 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: ' + 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: ' + path + ', context: ' + context + ')'); compare(schema[prop], response[prop], 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: ' + 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: ' + 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, 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: ' + 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: ' + path + ', context: ' + context + ')'); }); } // TOXO: fix -- premature exit for POST-only routes if (!readApi.paths[path].get) { return; } it('should have examples when parameters are present', () => { const parameters = readApi.paths[path].get.parameters; let testPath = path; if (parameters) { parameters.forEach((param) => { assert(param.example !== null && param.example !== undefined, 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') + testPath; }); it('should resolve with a 200 when called', async () => { await setupData(); try { response = await request(url, { jar: !unauthenticatedRoutes.includes(path) ? jar : undefined, json: true, headers: headers, qs: qs, }); } catch (e) { assert(!e, path + ' resolved with ' + e.message); } }); // Recursively iterate through schema properties, comparing type it('response should match schema definition', () => { const has200 = readApi.paths[path].get.responses['200']; if (!has200) { return; } const hasJSON = has200.content && has200.content['application/json']; if (hasJSON) { schema = readApi.paths[path].get.responses['200'].content['application/json'].schema; compare(schema, response, 'root'); } // TODO someday: text/csv, binary file type checking? }); }); }); describe('Write API', () => { let writeApi; });