Testing suite integration for openapi spec (#8263)

* feat: testing suite integration for openapi spec

The testing suite now takes the openapi spec into account. It will
check each route defined, make a call to it, and compare the
response with the defined schema. Any mismatches will cause the
test to fail.

* fix(openapi): removed debug stuff from tests

* fix(openapi): fixed some tests

* fix(openapi): added additional check to tests, test fixes

* fix(openapi): better tests, fixed spec errors

* fix(openapi): bad conditional in test

* fix: oops

* fix(openapi): more tests fixing

* fix(openapi): more tests

* fix(openapi): fix some more tests

* fix: verbose'd an info log

* fix: topic pagination route returns schema-optimized pagination block

* fix(openapi): more test/spec fixes

* fix(openapi): accidentally sending in authenticated jar for anon routes

* fix(openapi): more test/spec fixes

* fix(openapi): more spec fixes

* fix: timestampReadable Invalid Date

* fix(openapi): more tests... almost there

* fix(openapi): more tests fixing

* fix(openapi): finally all tests passing

* fix(openapi): added reverse test to compare response to spec

... and fixed all the tests that broke

* fix: remove tests related to group covers, as route is gone

* fix(openapi): broken test on travis

* fix(openapi): broken test on travis

* fix(openapi): broken test on travis

* fix(openapi): object cache is not present for psql

* fix: tests

Co-authored-by: Barış Soner Uşaklı <barisusakli@gmail.com>
v1.18.x
Julian Lam 5 years ago committed by GitHub
parent 6edf02d4a5
commit ccc6118d30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -109,6 +109,7 @@
"email:sendmail:rateLimit": 2,
"email:sendmail:rateDelta": 1000,
"hideFullname": 0,
"hideEmail": 0,
"allowGuestHandles": 0,
"disableRecentCategoryFilter": 0,
"maximumRelatedTopics": 0,

@ -104,6 +104,7 @@
"prompt": "^1.0.0",
"redis": "3.0.2",
"request": "2.88.2",
"request-promise-native": "^1.0.8",
"rimraf": "3.0.2",
"rss": "^1.2.2",
"sanitize-html": "^1.23.0",

@ -9,4 +9,8 @@ Breadcrumbs:
text:
type: string
url:
type: string
type: string
cid:
type: number
required:
- text

@ -47,10 +47,7 @@ CommonProps:
property:
type: string
required:
- name
- content
- noEscape
- property
link:
type: array
items:
@ -69,30 +66,14 @@ CommonProps:
required:
- rel
- href
- type
- sizes
widgets:
type: object
description: Rendered widgets
properties:
header:
type: array
items:
type: object
properties:
html:
type: string
sidebar:
type: array
items:
type: object
properties:
html:
type: string
footer:
type: array
items:
type: object
properties:
html:
type: string
description: Each widget area will have its own property in this object
additionalProperties:
type: array
description: A collection of HTML snippets that are appended to each widget area
items:
type: object
properties:
html:
type: string

@ -1,5 +1,6 @@
GroupObject:
GroupFullObject:
type: object
description: The response from an internal call to `Groups.get(<groupname>)`
properties:
name:
type: string
@ -14,7 +15,7 @@ GroupObject:
type: number
description: Label text for the user badge
userTitleEnabled:
type: boolean
type: number
description:
type: string
description: The group description
@ -73,3 +74,60 @@ GroupObject:
type: boolean
isOwner:
type: boolean
nullable: true
GroupDataObject:
type: object
description: The response from an internal call to `Groups.getGroupData(<groupname>, [])` with **explicitly** no fields passed in
properties:
name:
type: string
description: The group name
slug:
type: string
description: URL-safe slug of the group name
createtime:
type: number
description: UNIX timestamp of the group's creation
userTitle:
type: number
description: Label text for the user badge
userTitleEnabled:
type: number
description:
type: string
description: The group description
memberCount:
type: number
hidden:
type: number
system:
type: number
private:
type: number
disableJoinRequests:
type: number
disableLeave:
type: number
cover:url:
type: string
cover:thumb:url:
type: string
nameEncoded:
type: string
displayName:
type: string
description: A custom override of the group's name, a friendly name
labelColor:
type: string
description: A six-character hexadecimal colour code
textColor:
type: string
description: A six-character hexadecimal colour code
icon:
type: string
description: A FontAwesome icon string
createtimeISO:
type: string
description: "`createtime` rendered as an ISO 8601 format"
cover:position:
type: string

@ -34,10 +34,30 @@ Pagination:
type: boolean
rel:
type: array
items: {}
description: A collection of objects used to build the link tags pointing to adjacent pages, if any.
items:
type: object
properties:
rel:
type: string
enum: [prev, next]
href:
type: string
description: A query string that points to the previous or next page
pages:
type: array
items: {}
items:
type: object
properties:
page:
type: number
description: The current page
active:
type: boolean
description: If the page noted in this array is the current page
qs:
type: string
description: A query string that points to the page noted in this array
currentPage:
type: number
pageCount:

@ -41,6 +41,7 @@ PostsObject:
removed, etc.)
picture:
type: string
nullable: true
status:
type: string
icon:text:
@ -79,6 +80,10 @@ PostsObject:
type: number
description: The post id of the first post in this topic (also called the
"original post")
teaserPid:
type: number
description: The post id of the teaser (the most recent post, depending on settings)
nullable: true
titleRaw:
type: string
category:

@ -0,0 +1,253 @@
TopicObject:
type: object
properties:
tid:
type: number
description: A topic identifier
uid:
type: number
description: A user identifier
cid:
type: number
description: A category identifier
mainPid:
type: number
description: The post id of the first post in this topic (also called the
"original post")
title:
type: string
slug:
type: string
timestamp:
type: number
lastposttime:
type: number
postcount:
type: number
viewcount:
type: number
teaserPid:
oneOf:
- type: number
- type: string
nullable: true
upvotes:
type: number
downvotes:
type: number
deleted:
type: number
locked:
type: number
pinned:
type: number
description: Whether or not this particular topic is pinned to the top of the
category
deleterUid:
type: number
titleRaw:
type: string
timestampISO:
type: string
description: An ISO 8601 formatted date string (complementing `timestamp`)
lastposttimeISO:
type: string
votes:
type: number
category:
type: object
properties:
cid:
type: number
description: A category identifier
name:
type: string
slug:
type: string
icon:
type: string
image:
nullable: true
type: string
imageClass:
nullable: true
type: string
bgColor:
type: string
color:
type: string
disabled:
type: number
user:
type: object
properties:
uid:
type: number
description: A user identifier
username:
type: string
description: A friendly name for a given user account
fullname:
type: string
userslug:
type: string
description: An URL-safe variant of the username (i.e. lower-cased, spaces
removed, etc.)
reputation:
type: number
postcount:
type: number
picture:
type: string
nullable: true
signature:
type: string
nullable: true
banned:
type: number
status:
type: string
icon:text:
type: string
description: A single-letter representation of a username. This is used in the
auto-generated icon given to users without
an avatar
icon:bgColor:
type: string
description: A six-character hexadecimal colour code assigned to the user. This
value is used in conjunction with
`icon:text` for the user's auto-generated
icon
example: "#f44336"
banned_until_readable:
type: string
required:
- uid
- username
- userslug
- reputation
- postcount
- picture
- signature
- banned
- status
- icon:text
- icon:bgColor
- banned_until_readable
teaser:
type: object
properties:
pid:
type: number
uid:
type: number
description: A user identifier
timestamp:
type: number
tid:
type: number
description: A topic identifier
content:
type: string
timestampISO:
type: string
description: An ISO 8601 formatted date string (complementing `timestamp`)
user:
type: object
properties:
uid:
type: number
description: A user identifier
username:
type: string
description: A friendly name for a given user account
userslug:
type: string
description: An URL-safe variant of the username (i.e. lower-cased, spaces
removed, etc.)
picture:
nullable: true
type: string
icon:text:
type: string
description: A single-letter representation of a username. This is used in the
auto-generated icon given to users
without an avatar
icon:bgColor:
type: string
description: A six-character hexadecimal colour code assigned to the user. This
value is used in conjunction with
`icon:text` for the user's
auto-generated icon
example: "#f44336"
index:
type: number
nullable: true
tags:
type: array
items:
type: object
properties:
value:
type: string
valueEscaped:
type: string
color:
type: string
bgColor:
type: string
score:
type: number
isOwner:
type: boolean
ignored:
type: boolean
unread:
type: boolean
bookmark:
nullable: true
type: number
unreplied:
type: boolean
icons:
type: array
items:
type: string
description: HTML injected into the theme
index:
type: number
thumb:
type: string
required:
- tid
- uid
- cid
- mainPid
- title
- slug
- timestamp
- lastposttime
- postcount
- viewcount
- teaserPid
- upvotes
- downvotes
- deleted
- locked
- pinned
- deleterUid
- titleRaw
- timestampISO
- lastposttimeISO
- votes
- category
- user
- teaser
- tags
- isOwner
- ignored
- unread
- bookmark
- unreplied
- icons
- index

@ -33,34 +33,41 @@ UserObject:
type: string
description: A URL pointing to a picture to be used as the user's avatar
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
nullable: true
fullname:
type: string
example: Mr. Dragon Fruit Jr.
location:
type: string
example: 'Toronto, Canada'
nullable: true
birthday:
type: string
description: A birthdate given in an ISO format parseable by the Date object
example: 03/27/2020
nullable: true
website:
type: string
example: 'https://example.org'
nullable: true
aboutme:
type: string
example: |
This is a paragraph all about how my life got twist-turned upside-down
and I'd like to take a minute and sit right here,
to tell you all about how I because the administrator of NodeBB
to tell you all about how I became the administrator of NodeBB
nullable: true
signature:
type: string
example: |
This is an example signature
It can span multiple lines.
nullable: true
uploadedpicture:
type: string
example: /assets/profile/1-profileimg.png
description: 'In almost all cases, defer to "picture" instead. Use this if you need to specifically reference the picture uploaded to the forum.'
nullable: true
profileviews:
type: number
description: The number of times this user's profile has been viewed
@ -98,18 +105,21 @@ UserObject:
flags:
type: number
example: 0
followercount:
nullable: true
followerCount:
type: number
example: 2
followingcount:
followingCount:
type: number
example: 5
'cover:url':
type: string
example: /assets/profile/1-cover.png
nullable: true
'cover:position':
type: string
example: 50.0301% 19.2464%
nullable: true
groupTitle:
type: string
example: '["administrators","Staff"]'
@ -140,7 +150,45 @@ UserObject:
type: string
description: An ISO 8601 formatted date string representing the moment a ban will be lifted, or the words "Not Banned"
example: Not Banned
required:
- uid
- username
- userslug
- 'email:confirmed'
- joindate
- lastonline
- picture
- location
- birthday
- website
- aboutme
- signature
- uploadedpicture
- profileviews
- reputation
- postcount
- topiccount
- lastposttime
- banned
- 'banned:expire'
- status
- enum
- flags
- followerCount
- followingCount
- 'cover:url'
- 'cover:position'
- groupTitle
- groupTitleArray
- example
- 'icon:text'
- 'icon:bgColor'
- joindateISO
- lastonlineISO
- banned_until
- banned_until_readable
UserObjectFull:
# accountHelpers.getUserDataByUserSlug
type: object
properties:
uid:
@ -175,6 +223,7 @@ UserObjectFull:
type: string
description: A URL pointing to a picture to be used as the user's avatar
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
nullable: true
fullname:
type: string
example: Mr. Dragon Fruit Jr.
@ -193,7 +242,7 @@ UserObjectFull:
example: |
This is a paragraph all about how my life got twist-turned upside-down
and I'd like to take a minute and sit right here,
to tell you all about how I because the administrator of NodeBB
to tell you all about how I became the administrator of NodeBB
signature:
type: string
example: |
@ -203,6 +252,7 @@ UserObjectFull:
type: string
example: /assets/profile/1-profileimg.png
description: 'In almost all cases, defer to "picture" instead. Use this if you need to specifically reference the picture uploaded to the forum.'
nullable: true
profileviews:
type: number
description: The number of times this user's profile has been viewed
@ -240,18 +290,21 @@ UserObjectFull:
flags:
type: number
example: 0
followercount:
nullable: true
followerCount:
type: number
example: 2
followingcount:
followingCount:
type: number
example: 5
'cover:url':
type: string
example: /assets/profile/1-cover.png
nullable: true
'cover:position':
type: string
example: 50.0301% 19.2464%
nullable: true
groupTitle:
type: string
example: '["administrators","Staff"]'
@ -332,7 +385,8 @@ UserObjectFull:
type: boolean
groups:
type: array
items: {}
items:
$ref: ./GroupObject.yaml#/GroupFullObject
disableSignatures:
type: boolean
reputation:disabled:
@ -369,6 +423,12 @@ UserObjectFull:
type: boolean
icon:
type: string
required:
- id
- route
- name
- visibility
- public
sso:
type: array
items:
@ -411,6 +471,7 @@ UserObjectSlim:
type: string
description: A URL pointing to a picture to be used as the user's avatar
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
nullable: true
status:
type: string
enum:
@ -437,6 +498,7 @@ UserObjectSlim:
flags:
type: number
example: 0
nullable: true
banned:
type: number
description: A Boolean representing whether a user is banned or not
@ -473,3 +535,74 @@ UserObjectSlim:
example: Not Banned
administrator:
type: boolean
UserObjectACP:
type: object
properties:
uid:
type: number
description: A user identifier
example: 1
username:
type: string
description: A friendly name for a given user account
example: Dragon Fruit
userslug:
type: string
description: An URL-safe variant of the username (i.e. lower-cased, spaces removed, etc.)
example: dragon-fruit
email:
type: string
description: Email address associated with the user account
example: dragonfruit@example.org
postcount:
type: number
example: 1000
joindate:
type: number
description: A UNIX timestamp representing the moment the user's account was created
example: 1585337827953
banned:
type: number
description: A Boolean representing whether a user is banned or not
example: 0
reputation:
type: number
description: The user's reputation score on the forum. Out-of-the-box, users gain/lose reputation points based on upvotes/downvotes, though plugins can alter the logic and criterion for awarding reputation points
example: 100
picture:
type: string
description: A URL pointing to a picture to be used as the user's avatar
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
nullable: true
flags:
type: number
example: 0
nullable: true
lastonline:
type: number
description: A UNIX timestamp representing the moment the user was last recorded online on this site
example: 1585337827953
'email:confirmed':
type: number
description: Whether the user has confirmed their email address or not
example: 1
'icon:text':
type: string
description: A single-letter representation of a username. This is used in the auto-generated icon given to users without an avatar
example: D
'icon:bgColor':
type: string
description: A six-character hexadecimal colour code assigned to the user. This value is used in conjunction with `icon:text` for the user's auto-generated icon
example: '#9c27b0'
joindateISO:
type: string
example: '2020-03-27T20:30:36.590Z'
lastonlineISO:
type: string
example: '2020-03-27T20:30:36.590Z'
banned_until_readable:
type: string
description: An ISO 8601 formatted date string representing the moment a ban will be lifted, or the words "Not Banned"
example: Not Banned
administrator:
type: boolean

File diff suppressed because it is too large Load Diff

@ -100,7 +100,7 @@ define('forum/topic/posts', [
function updatePagination() {
$.get(config.relative_path + '/api/topic/pagination/' + ajaxify.data.tid, { page: ajaxify.data.pagination.currentPage }, function (paginationData) {
app.parseAndTranslate('partials/paginator', { pagination: paginationData }, function (html) {
app.parseAndTranslate('partials/paginator', paginationData, function (html) {
$('[component="pagination"]').after(html).remove();
});
});

@ -19,7 +19,7 @@ consentController.get = async function (req, res, next) {
const consented = await db.getObjectField('user:' + userData.uid, 'gdpr_consent');
userData.gdpr_consent = parseInt(consented, 10) === 1;
userData.digest = {
frequency: meta.config.dailyDigestFreq,
frequency: meta.config.dailyDigestFreq || 'off',
enabled: meta.config.dailyDigestFreq !== 'off',
};

@ -143,6 +143,7 @@ async function getProfileMenu(uid, callerUID) {
id: 'info',
route: 'info',
name: '[[user:account_info]]',
icon: 'fa-info',
visibility: {
self: false,
other: false,
@ -155,6 +156,7 @@ async function getProfileMenu(uid, callerUID) {
id: 'sessions',
route: 'sessions',
name: '[[pages:account/sessions]]',
icon: 'fa-group',
visibility: {
self: true,
other: false,
@ -170,6 +172,7 @@ async function getProfileMenu(uid, callerUID) {
id: 'consent',
route: 'consent',
name: '[[user:consent.title]]',
icon: 'fa-thumbs-o-up',
visibility: {
self: true,
other: false,
@ -190,6 +193,8 @@ async function getProfileMenu(uid, callerUID) {
async function parseAboutMe(userData) {
if (!userData.aboutme) {
userData.aboutme = '';
userData.aboutmeParsed = '';
return;
}
userData.aboutme = validator.escape(String(userData.aboutme || ''));

@ -106,12 +106,12 @@ settingsController.get = async function (req, res, next) {
userData.categoryWatchState = { [userData.settings.categoryWatchState]: true };
userData.disableCustomUserSkins = meta.config.disableCustomUserSkins;
userData.disableCustomUserSkins = meta.config.disableCustomUserSkins || 0;
userData.allowUserHomePage = meta.config.allowUserHomePage;
userData.allowUserHomePage = meta.config.allowUserHomePage || 1;
userData.hideFullname = meta.config.hideFullname;
userData.hideEmail = meta.config.hideEmail;
userData.hideFullname = meta.config.hideFullname || 0;
userData.hideEmail = meta.config.hideEmail || 0;
userData.inTopicSearchAvailable = plugins.hasListeners('filter:topic.search');

@ -28,11 +28,17 @@ infoController.get = function (req, res) {
}
return 0;
});
let port = nconf.get('port');
if (!Array.isArray(port) && !isNaN(parseInt(port, 10))) {
port = [port];
}
res.render('admin/development/info', {
info: data,
infoJSON: JSON.stringify(data, null, 4),
host: os.hostname(),
port: nconf.get('port'),
port: port,
nodeCount: data.length,
timeout: timeoutMS,
ip: req.ip,

@ -103,7 +103,7 @@ categoryController.get = async function (req, res, next) {
addTags(categoryData, res);
categoryData['feeds:disableRSS'] = meta.config['feeds:disableRSS'];
categoryData['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0;
categoryData['reputation:disabled'] = meta.config['reputation:disabled'];
pageCount = Math.max(1, Math.ceil(categoryData.topic_count / userSettings.topicsPerPage));
categoryData.pagination = pagination.create(currentPage, pageCount, req.query);

@ -62,9 +62,9 @@ recentController.getData = async function (req, url, sort) {
data.canPost = canPost;
data.categories = categoryData.categories;
data.allCategoriesUrl = url + helpers.buildQueryString('', filter, '');
data.selectedCategory = categoryData.selectedCategory;
data.selectedCategory = categoryData.selectedCategory || null;
data.selectedCids = categoryData.selectedCids;
data['feeds:disableRSS'] = meta.config['feeds:disableRSS'];
data['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0;
data.rssFeedUrl = nconf.get('relative_path') + '/' + url + '.rss';
if (req.loggedIn) {
data.rssFeedUrl += '?uid=' + req.uid + '&token=' + rssToken;

@ -32,10 +32,6 @@ tagsController.getTag = async function (req, res) {
helpers.getCategoriesByStates(req.uid, '', states),
]);
if (Array.isArray(tids) && !tids.length) {
return res.render('tag', templateData);
}
templateData.categories = categoriesData.categories;
templateData.topics = await topics.getTopics(tids, req.uid);

@ -70,16 +70,12 @@ topicsController.get = async function getTopic(req, res, callback) {
topics.modifyPostsByPrivilege(topicData, userPrivileges);
const hookData = await plugins.fireHook('filter:controllers.topic.get', { topicData: topicData, uid: req.uid });
await Promise.all([
buildBreadcrumbs(hookData.topicData),
addTags(topicData, req, res),
]);
topicData.privileges = userPrivileges;
topicData.topicStaleDays = meta.config.topicStaleDays;
topicData['reputation:disabled'] = meta.config['reputation:disabled'];
topicData['downvote:disabled'] = meta.config['downvote:disabled'];
topicData['feeds:disableRSS'] = meta.config['feeds:disableRSS'];
topicData['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0;
topicData.bookmarkThreshold = meta.config.bookmarkThreshold;
topicData.necroThreshold = meta.config.necroThreshold;
topicData.postEditDuration = meta.config.postEditDuration;
@ -99,6 +95,11 @@ topicsController.get = async function getTopic(req, res, callback) {
res.locals.linkTags.push(rel);
});
await Promise.all([
buildBreadcrumbs(hookData.topicData),
addTags(topicData, req, res),
]);
incrementViewCount(req, tid);
markAsRead(req, tid);
@ -338,5 +339,5 @@ topicsController.pagination = async function (req, res, callback) {
rel.href = nconf.get('url') + '/topic/' + topic.slug + rel.href;
});
res.json(paginationData);
res.json({ pagination: paginationData });
};

@ -178,7 +178,7 @@ userController.exportUploads = function (req, res, next) {
});
archive.pipe(output);
winston.info('[user/export/uploads] Collating uploads for uid ' + targetUid);
winston.verbose('[user/export/uploads] Collating uploads for uid ' + targetUid);
user.collateUploads(targetUid, archive, function (err) {
if (err) {
return next(err);

@ -43,7 +43,6 @@ redisModule.init = function (callback) {
winston.error('NodeBB could not connect to your Redis database. Redis returned the following error', err);
return callback(err);
}
require('./redis/promisify')(redisModule.client);
callback();

@ -100,6 +100,7 @@ Flags.get = async function (flagId) {
const flagObj = {
state: 'open',
assignee: null,
...base,
description: validator.escape(base.description),
datetimeISO: utils.toISOString(base.datetime),
@ -164,6 +165,7 @@ Flags.list = async function (filters, uid) {
const userObj = await user.getUserFields(flagObj.uid, ['username', 'picture']);
flagObj = {
state: 'open',
assignee: null,
...flagObj,
reporter: {
username: userObj.username,

@ -82,6 +82,7 @@ module.exports = function (Messaging) {
messages = await Promise.all(messages.map(async (message) => {
if (message.system) {
message.content = validator.escape(String(message.content));
message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(message.content));
return message;
}

@ -60,6 +60,9 @@ admin.get = async function () {
async function getAvailable() {
const core = require('../../install/data/navigation.json').map(function (item) {
item.core = true;
item.id = item.id || '';
item.properties = item.properties || { targetBlank: false };
return item;
});

@ -74,7 +74,7 @@ module.exports = function (Posts) {
}
async function getTopicAndCategories(tids) {
const topicsData = await topics.getTopicsFields(tids, ['uid', 'tid', 'title', 'cid', 'slug', 'deleted', 'postcount', 'mainPid']);
const topicsData = await topics.getTopicsFields(tids, ['uid', 'tid', 'title', 'cid', 'slug', 'deleted', 'postcount', 'mainPid', 'teaserPid']);
const cids = _.uniq(topicsData.map(topic => topic && topic.cid));
const categoriesData = await categories.getCategoriesFields(cids, ['cid', 'name', 'icon', 'slug', 'parentCid', 'bgColor', 'color', 'image', 'imageClass']);
return { topics: topicsData, categories: categoriesData };

@ -106,4 +106,8 @@ function modifyTopic(topic, fields) {
if (topic.hasOwnProperty('upvotes') && topic.hasOwnProperty('downvotes')) {
topic.votes = topic.upvotes - topic.downvotes;
}
if (fields.includes('teaserPid') || !fields.length) {
topic.teaserPid = topic.teaserPid || null;
}
}

@ -102,7 +102,7 @@ Topics.getTopicsByTids = async function (tids, options) {
if (topics[i]) {
topics[i].category = categoriesMap[topics[i].cid];
topics[i].user = usersMap[topics[i].uid];
topics[i].teaser = teasers[i];
topics[i].teaser = teasers[i] || null;
topics[i].tags = tags[i];
topics[i].isOwner = topics[i].uid === parseInt(uid, 10);

@ -101,7 +101,7 @@ module.exports = function (User) {
banObj.user = usersData[index];
banObj.until = parseInt(banObj.expire, 10);
banObj.untilReadable = new Date(banObj.until).toString();
banObj.timestampReadable = new Date(banObj.timestamp).toString();
banObj.timestampReadable = new Date(parseInt(banObj.timestamp, 10)).toString();
banObj.timestampISO = utils.toISOString(banObj.timestamp);
banObj.reason = validator.escape(String(banObj.reason || '')) || '[[user:info.banned-no-reason]]';
return banObj;

@ -3,18 +3,226 @@
const assert = require('assert');
const path = require('path');
const SwaggerParser = require('@apidevtools/swagger-parser');
const request = require('request-promise-native');
const nconf = require('nconf');
describe('Read API', () => {
let readApi;
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 () => {
const apiPath = path.resolve(__dirname, '../public/openapi/read.yaml');
try {
readApi = await SwaggerParser.validate(apiPath);
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', () => {

@ -1071,7 +1071,7 @@ describe('Topic\'s', function () {
assert.ifError(err);
assert.equal(response.statusCode, 200);
assert(body);
assert.deepEqual(body, {
assert.deepEqual(body.pagination, {
prev: { page: 1, active: false },
next: { page: 1, active: false },
first: { page: 1, active: true },

Loading…
Cancel
Save