'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; } 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})`); }); } });