mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-27 17:16:14 +01:00
* chore: up deps * chore: up composer * fix(deps): bump 2factor to v7 * chore: up harmony * chore: up harmony * fix: missing await * feat: allow middlewares to pass in template values via res.locals * feat: buildAccountData middleware automatically added ot all account routes * fix: properly allow values in res.locals.templateValues to be added to the template data * refactor: user/blocks * refactor(accounts): categories and consent * feat: automatically 404 if exposeUid or exposeGroupName come up empty * refactor: remove calls to getUserDataByUserSlug for most account routes, since it is populated via middleware now * fix: allow exposeUid and exposeGroupName to work with slugs with mixed capitalization * fix: move reputation removal check to accountHelpers method * test: skip i18n tests if ref branch when present is not develop * fix(deps): bump theme versions * fix(deps): bump ntfy and 2factor * chore: up harmony * fix: add missing return * fix: #11191, only focus on search input on md environments and up * feat: allow file uploads on mobile chat closes https://github.com/NodeBB/NodeBB/issues/11217 * chore: up themes * chore: add lang string * fix(deps): bump ntfy to 1.0.15 * refactor: use new if/each syntax * chore: up composer * fix: regression from user helper refactor * chore: up harmony * chore: up composer * chore: up harmony * chore: up harmony * chore: up harmony * chore: fix composer version * feat: add increment helper * chore: up harmony * fix: #11228 no timestamps in future ⌛ * chore: up harmony * check config.theme as well fire action:posts.loaded after processing dom * chore: up harmony * chore: up harmony * chore: up harmony * chore: up themes * chore: up harmony * remove extra class * refactor: move these to core from harmony * chore: up widgets * chore: up widgets * height auto * fix: closes #11238 * dont focus inputs, annoying on mobile * fix: dont focus twice, only focus on chat input on desktop dont wrap widget footer in row * chore: up harmony * chore: up harmony * update chat window * chore: up themes * fix cache buster for skins * chat fixes * chore: up harmony * chore: up composer * refactor: change hook logs to debug * fix: scroll to post right after adding to dom * fix: hash scrolling and highlighting correct post * test: re-enable read API schema tests * fix: add back schema changes for179faa2270andc3920ccb10* fix: schema changes from488f0978a4* fix: schema changes forf4cf482a87* fix: schema update forbe6bbabd0e* fix: schema changes for69c96078ea* fix: schema changes ford1364c3130* fix: schema changes for84ff1152f7* fix: schema changes forb860c2605c* fix: schema changes for23cb67a112* fix: schema changes forb916e42f40* fix: schema change fora9bbb586fc* fix: schema changes for4b738c8cd3* fix: schema changes for58b5781cea* fix: schema changes for794bf01b21* fix: schema changes for80ea12c1c1,e368feef51, and52ead114be* fix: composer-default object in config? * fix: schema changes for9acdc6808cand0930934200* fix: schema changes forc0a52924f1* fix: schema change foraba420a3f3, move loggedInUser to optional props * fix: schema changes for8c67031609* fix: schema changes for27e53b42f3* fix: schema changes for2835966518* fix: breaking test for email confirmation API call * fix: schema changes for refactored search page * fix: schema changes for user object * fix: schema changes for9f531f957e* fix: schema changes forc4042c70deand23175110a2* fix: schema changes for9b3616b103* fix: schema changes for5afd5de07d* fix: schema change for1d7baf1217* fix: schema changes for57bfb37c55andbe6bbabd0e* fix: schema changes for6e86b4afa2and3efad2e13band68f66223e7* fix: allowing optional qs prop in pagination keys (not sure why this didn't break before) * fix: re-login on email change * fix: schema changes forc926358d73* fix: schema changes for388a8270c9* fix: schema change for2658bcc821* fix: no need to call account middlewares for chats routes * fix: schema changes for71743affc3* fix: final schema changes * test: support for anyOf and oneOf * fix: check thumb * dont scroll to top on back press * remove group log * fix: add top margin to merged and deleted alerts * chore: up widgets * fix: improve fix-lists mixin * chore: up harmony/composer * feat: allow hiding quicksearch results during search * dont record searches made by composer * chore: up 54 * chore: up spam be gone * feat: add prev/next page and page count into mobile paginator * chore: up harmony * chore: up harmony * use old style for IS * fix: hide entire toolbar row if no posts or not singlePost * fix: updated messaging for post-queue template, #11206 * fix: btn-sm on post queue back button * fix: bump harmony, closes #11206 * fix: remove unused alert module import * fix: bump harmony * fix: bump harmony * chore: up harmony * refactor: IS scrolltop * fix: update users:search-user-for-chat source string * feat: support for mark-read toggle on chats dropdown and recent chats list * feat: api v3 calls to mark chat read/unread * feat: send event:chats.mark socket event on mark read or unread * refactor: allow frontend to mark chats as unread, use new API v3 routes instead of socket calls, better frontend event handling * docs: openapi schema updates for chat marking * fix: allow unread state toggling in chats dropdown too * fix: issue where repeated openings of the chats dropdown would continually add events for mark-read/unread * fix: debug log * refactor: move userSearch filter to a module * feat(routes): allow remounting /categories (#11230) * feat: send flags count to frontend on flags list page * refactor: filter form client-side js to extract out some logic * fix: applyFilters to not take any arguments, update selectedCids in updateButton instead of onHidden * fix: use userFilter module for assignee, reporterId, targetUid * fix(openapi): schema changes for updated flags page * fix: dont allow adding duplicates to userFilter * use same var * remove log * fix: closes #11282 * feat: lang key for x-topics * chore: up harmony * chore: up emoji * chore: up harmony * fix: update userFilter to allow new option `selectedBlock` * fix: wrong block name passed to userFilter * fix: https://github.com/NodeBB/NodeBB/issues/11283 * fix: chats, allow multiple dropdowns like in harmony * chore: up harmony * refactor: flag note adding/editing, closes #11285 * fix: remove old prepareEdit logic * chore: add caveat about hacky code block in userFilter module * fix: placeholders for userFilter module * refactor: navigator so it works with multiple thumbs/navigators * chore: up harmony * fix: closes #11287, destroy quick reply autocomplete on navigation * fix: filter disabled categories on user categories page count * chore: up harmony * docs: update openapi spec to include info about passing in timestamps for topic creation, removing timestamp as valid request param for topic replying * fix: send back null values on ACP search dashboard for startDate and endDate if not expicitly passed in, fix tests * fix: tweak table order in ACP dash searches * fix: only invoke navigator click drag on left mouse button * feat: add back unread indicator to navigator * clear bookmark on mark unread * fix: navigator crash on ajaxify * better thumb top calculation * fix: reset user bookmark when topic is marked unread * Revert "fix: reset user bookmark when topic is marked unread" This reverts commit9bcd85c2c6. * fix: update unread indicator on scroll, add unread count * chore: bump harmony * fix: crash on navigator unread update when backing out of a topic * fix: closes #11183 * fix: update topics:recent zset when rescheduling a topic * fix: dupe quote button, increase delay, hide immediately on empty selection * fix: navigator not showing up on first load * refactor: remove glance assorted fixes to navigator dont reduce remaning count if user scrolls down and up quickly only call topic.navigatorCallback when index changes * more sanity checks for bookmark dont allow setting bookmark higher than topic postcount * closes #11218, 🚋 * Revert "fix: update topics:recent zset when rescheduling a topic" This reverts commit737973cca9. * fix: #11306, show proper error if queued post doesn't exist was showing no-privileges if someone else accepted the post * https://github.com/NodeBB/NodeBB/issues/11307 dont use li * chore: up harmony * chore: bump version string * fix: copy paste fail * feat: closes #7382, tag filtering add client side support for filtering by tags on /category, /recent and /unread * chore: up harmony * chore: up harmony * Revert "fix: add back req.query fallback for backwards compatibility" [breaking] This reverts commitcf6cc2c454. This commit is no longer required as passing in a CSRF token via query parameter is no longer supported as of NodeBB v3.x This is a breaking change. * fix: pass csrf token in form data, re: NodeBB/NodeBB#11309 * chore: up deps * fix: tests, use x-csrf-token query param removed * test: fix csrf_token * lint: remove unused * feat: add itemprop="image" to avatar helper * fix: get chat upload button in chat modal * breaking: remove deprecated socket.io methods * test: update messaging tests to not use sockets * fix: parent post links * fix: prevent post tooltip if mouse leaves before data/tpl is loaded * chore: up harmony * chore: up harmony * chore: up harmony * chore: up harmony * fix: nested replies indices * fix(deps): bump 2factor * feat: add loggedIn user to all api routes * chore: up themes * refactor: audit admin v3 write api routes as per #11321 * refactor: audit category v3 write api routes as per #11321 [breaking] docs: fix open api spec for #11321 * refactor: audit chat v3 write api routes as per #11321 * refactor: audit files v3 write api routes as per #11321 * refactor: audit flags v3 write api routes as per #11321 * refactor: audit posts v3 write api routes as per #11321 * refactor: audit topics v3 write api routes as per #11321 * refactor: audit users v3 write api routes as per #11321 * fix: lang string * remove min height * fix: empty topic/labels taking up space * fix: tag filtering when changing filter to watched topics or changing popular time limit to month * chore: up harmony * fix: closes #11354, show no post error if queued post already accepted/rejected * test: #11354 * test: #11354 * fix(deps): bump 2factor * fix: #11357 clear cache on thumb remove * fix: thumb remove on windows, closes #11357 * test: openapi for thumbs * test: fix openapi --------- Co-authored-by: Julian Lam <julian@nodebb.org> Co-authored-by: Opliko <opliko.reg@protonmail.com>
584 lines
20 KiB
JavaScript
584 lines
20 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...
|
|
},
|
|
],
|
|
},
|
|
post: {},
|
|
put: {},
|
|
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...
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
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 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 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, [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.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})`);
|
|
});
|
|
}
|
|
});
|