Merge branch 'master' into experimentalEventStreamEmitter

This commit is contained in:
G.Ambatte
2026-09-20 15:30:07 +12:00
228 changed files with 22687 additions and 16470 deletions
+283 -228
View File
@@ -4,64 +4,83 @@ import { model as NotificationModel } from './notifications.model.js';
import express from 'express';
import Moment from 'moment';
import zlib from 'zlib';
import templateFn from '../client/template.js';
import config from './config.js';
import path from 'path';
import fs from 'fs-extra';
const nodeEnv = config.get('node_env');
const isProd = nodeEnv === 'production';
import HomebrewAPI from './homebrew.api.js';
import asyncHandler from 'express-async-handler';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const router = express.Router();
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
const mw = {
adminOnly : (req, res, next)=>{
if(!req.get('authorization')){
return res
export default function createAdminApi(vite) {
const router = express.Router();
const mw = {
adminOnly : (req, res, next)=>{
if(!req.get('authorization')){
return res
.set('WWW-Authenticate', 'Basic realm="Authorization Required"')
.status(401)
.send('Authorization Required');
}
const [username, password] = Buffer.from(req.get('authorization').split(' ').pop(), 'base64')
}
const [username, password] = Buffer.from(req.get('authorization').split(' ').pop(), 'base64')
.toString('ascii')
.split(':');
if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){
return next();
if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){
return next();
}
throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
}
throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
}
};
};
// Search for up to 300 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
const junkBrewsPipeline = [
{ $match : {
updatedAt : { $lt: Moment().subtract(30, 'days').toDate() },
lastViewed : { $lt: Moment().subtract(30, 'days').toDate() }
} },
{ $project: { _id: 1, textBinSize: { $binarySize: '$textBin' }, updatedAt: 1, lastViewed: 1} },
{ $match: { textBinSize: { $lt: 140 } } },
{ $limit: 300 }
];
const junkBrewPipeline = [
{ $match : {
updatedAt : { $lt: Moment().subtract(30, 'days').toDate() },
lastViewed : { $lt: Moment().subtract(30, 'days').toDate() }
} },
{ $project: { textBinSize: { $binarySize: '$textBin' } } },
{ $match: { textBinSize: { $lt: 140 } } },
{ $limit: 100 }
];
// Search for up to 500 unauthored brews that have not been viewed or updated in two years
const lostBrewsPipeline = [
{
$match: {
authors: [],
updatedAt: { $lt: Moment().subtract(2, 'years').toDate() },
lastViewed: { $lt: Moment().subtract(2, 'years').toDate() }
}
},
{
$limit: 500
}
];
/* Search for brews that aren't compressed (missing the compressed text field) */
const uncompressedBrewQuery = HomebrewModel.find({
'text' : { '$exists': true }
}).lean().limit(10000).select('_id');
/* Search for brews that aren't compressed (missing the compressed text field) */
const uncompressedBrewQuery = HomebrewModel.find({
'text' : { '$exists': true }
}).lean().limit(10000).select('_id');
// Search for up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.get('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length }))
router.get('/admin/cleanupJunk', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewsPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length, brewCollection : objs }))
.catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
});
// Delete up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
// Delete result of junkBrewsPipeline
router.post('/admin/cleanupJunk', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewsPipeline).option({ maxTimeMS: 60000 })
.then((docs)=>{
const ids = docs.map((doc)=>doc._id);
return HomebrewModel.deleteMany({ _id: { $in: ids } });
@@ -71,18 +90,41 @@ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
});
/* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
return res.json(req.brew);
});
router.get('/admin/cleanupLost', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(lostBrewsPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length, brewCollection : objs }))
.catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
/* Find 50 brews that aren't compressed yet */
router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
const query = uncompressedBrewQuery.clone();
// Delete result of lostBrewsPipeline
router.post('/admin/cleanupLost', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(lostBrewsPipeline).option({ maxTimeMS: 60000 })
.then((docs)=>{
const ids = docs.map((doc)=>doc._id);
return HomebrewModel.deleteMany({ _id: { $in: ids } });
}).then((result)=>{
res.json({ count: result.deletedCount });
}).catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
query.exec()
/* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
return res.json(req.brew);
});
/* Find 50 brews that aren't compressed yet */
router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
const query = uncompressedBrewQuery.clone();
query.exec()
.then((objs)=>{
const ids = objs.map((obj)=>obj._id);
res.json({ count: ids.length, ids });
@@ -91,46 +133,46 @@ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
console.error(err);
res.status(500).send(err.message || 'Internal Server Error');
});
});
/* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Cleaning script tags from ShareID ${req.params.id}`);
function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
const brew = req.brew;
const properties = ['text', 'description', 'title'];
properties.forEach((property)=>{
brew[property] = cleanText(brew[property]);
});
splitTextStyleAndMetadata(brew);
/* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Cleaning script tags from ShareID ${req.params.id}`);
req.body = brew;
function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
// Remove Account from request to prevent Admin user from being added to brew as an Author
req.account = undefined;
const brew = req.brew;
return await HomebrewAPI.updateBrew(req, res);
});
const properties = ['text', 'description', 'title'];
properties.forEach((property)=>{
brew[property] = cleanText(brew[property]);
});
/* Get list of a user's documents */
router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
const username = req.params.user;
const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists
splitTextStyleAndMetadata(brew);
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Get brew list for ${username}`);
req.body = brew;
const brews = await HomebrewModel.getByUser(username, true, fields);
// Remove Account from request to prevent Admin user from being added to brew as an Author
req.account = undefined;
return res.json(brews);
});
return await HomebrewAPI.updateBrew(req, res);
});
/* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{
HomebrewModel.findOne({ _id: req.params.id })
/* Get list of a user's documents */
router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
const username = req.params.user;
const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Get brew list for ${username}`);
const brews = await HomebrewModel.getByUser(username, true, fields);
return res.json(brews);
});
/* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{
HomebrewModel.findOne({ _id: req.params.id })
.then((brew)=>{
if(!brew)
return res.status(404).send('Brew not found');
@@ -147,239 +189,252 @@ router.put('/admin/compress/:id', (req, res)=>{
console.error(err);
res.status(500).send('Error while saving');
});
});
});
router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
try {
const totalBrewsCount = await HomebrewModel.countDocuments({});
const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true });
router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
try {
const totalBrewsCount = await HomebrewModel.countDocuments({});
const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true });
return res.json({
totalBrews : totalBrewsCount,
totalPublishedBrews : publishedBrewsCount
});
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Internal Server Error' });
}
});
return res.json({
totalBrews : totalBrewsCount,
totalPublishedBrews : publishedBrewsCount
});
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Internal Server Error' });
}
});
// ####################### LOCKS
// ####################### LOCKS
router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{
router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksQuery = {
lock : { $exists: true }
};
const count = await HomebrewModel.countDocuments(countLocksQuery)
const countLocksQuery = {
lock : { $exists: true }
};
const count = await HomebrewModel.countDocuments(countLocksQuery)
.catch((error)=>{
throw { name: 'Lock Count Error', message: 'Unable to get lock count', status: 500, HBErrorCode: '61', error };
});
return res.json({ count });
return res.json({ count });
}));
}));
router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksPipeline = [
{
router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksPipeline = [
{
$match :
{
'lock' : { '$exists': 1 }
},
},
{
$project : {
shareId : 1,
editId : 1,
title : 1,
lock : 1
},
{
$project : {
shareId : 1,
editId : 1,
title : 1,
lock : 1
}
}
}
];
const lockedDocuments = await HomebrewModel.aggregate(countLocksPipeline)
];
const lockedDocuments = await HomebrewModel.aggregate(countLocksPipeline)
.catch((error)=>{
throw { name: 'Can Not Get Locked Brews', message: 'Unable to get locked brew collection', status: 500, HBErrorCode: '68', error };
});
return res.json({
lockedDocuments
});
return res.json({
lockedDocuments
});
}));
}));
router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const lock = req.body;
const lock = req.body;
lock.applied = new Date;
lock.applied = new Date;
const filter = {
shareId : req.params.id
};
const filter = {
shareId : req.params.id
};
const brew = await HomebrewModel.findOne(filter);
const brew = await HomebrewModel.findOne(filter);
if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to lock', shareId: req.params.id, status: 500, HBErrorCode: '63' };
if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to lock', shareId: req.params.id, status: 500, HBErrorCode: '63' };
if(brew.lock && !lock.overwrite) {
throw { name: 'Already Locked', message: 'Lock already exists on brew', shareId: req.params.id, title: brew.title, status: 500, HBErrorCode: '64' };
}
if(brew.lock && !lock.overwrite) {
throw { name: 'Already Locked', message: 'Lock already exists on brew', shareId: req.params.id, title: brew.title, status: 500, HBErrorCode: '64' };
}
lock.overwrite = undefined;
lock.overwrite = undefined;
brew.lock = lock;
brew.markModified('lock');
brew.lock = lock;
brew.markModified('lock');
await brew.save()
await brew.save()
.catch((error)=>{
throw { name: 'Lock Error', message: 'Unable to set lock', shareId: req.params.id, status: 500, HBErrorCode: '62', error };
});
return res.json({ name: 'LOCKED', message: `Lock applied to brew ID ${brew.shareId} - ${brew.title}`, ...lock });
return res.json({ name: 'LOCKED', message: `Lock applied to brew ID ${brew.shareId} - ${brew.title}`, ...lock });
}));
}));
router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = {
shareId : req.params.id
};
const filter = {
shareId : req.params.id
};
const brew = await HomebrewModel.findOne(filter);
const brew = await HomebrewModel.findOne(filter);
if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to unlock', shareId: req.params.id, status: 500, HBErrorCode: '66' };
if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to unlock', shareId: req.params.id, status: 500, HBErrorCode: '66' };
if(!brew.lock) throw { name: 'Not Locked', message: 'Cannot unlock as brew is not locked', shareId: req.params.id, status: 500, HBErrorCode: '67' };
if(!brew.lock) throw { name: 'Not Locked', message: 'Cannot unlock as brew is not locked', shareId: req.params.id, status: 500, HBErrorCode: '67' };
brew.lock = undefined;
brew.markModified('lock');
brew.lock = undefined;
brew.markModified('lock');
await brew.save()
await brew.save()
.catch((error)=>{
throw { name: 'Cannot Unlock', message: 'Unable to clear lock', shareId: req.params.id, status: 500, HBErrorCode: '65', error };
});
return res.json({ name: 'Unlocked', message: `Lock removed from brew ID ${req.params.id}` });
}));
return res.json({ name: 'Unlocked', message: `Lock removed from brew ID ${req.params.id}` });
}));
router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{
const countReviewsPipeline = [
{
router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{
const countReviewsPipeline = [
{
$match :
{
'lock.reviewRequested' : { '$exists': 1 }
},
},
{
$project : {
shareId : 1,
editId : 1,
title : 1,
lock : 1
},
{
$project : {
shareId : 1,
editId : 1,
title : 1,
lock : 1
}
}
}
];
const reviewDocuments = await HomebrewModel.aggregate(countReviewsPipeline)
];
const reviewDocuments = await HomebrewModel.aggregate(countReviewsPipeline)
.catch((error)=>{
throw { name: 'Can Not Get Reviews', message: 'Unable to get review collection', status: 500, HBErrorCode: '68', error };
});
return res.json({
reviewDocuments
});
return res.json({
reviewDocuments
});
}));
}));
router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{
router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{
// === This route is NOT Admin only ===
// Any user can request a review of their document
const filter = {
shareId : req.params.id,
lock : { $exists: 1 }
};
const filter = {
shareId : req.params.id,
lock : { $exists: 1 }
};
const brew = await HomebrewModel.findOne(filter);
if(!brew) { throw { name: 'Brew Not Found', message: `Cannot find a locked brew with ID ${req.params.id}`, code: 500, HBErrorCode: '70' }; };
const brew = await HomebrewModel.findOne(filter);
if(!brew) { throw { name: 'Brew Not Found', message: `Cannot find a locked brew with ID ${req.params.id}`, code: 500, HBErrorCode: '70' }; };
if(brew.lock.reviewRequested){
throw { name: 'Review Already Requested', message: `Review already requested for brew ${brew.shareId} - ${brew.title}`, code: 500, HBErrorCode: '71' };
};
if(brew.lock.reviewRequested){
throw { name: 'Review Already Requested', message: `Review already requested for brew ${brew.shareId} - ${brew.title}`, code: 500, HBErrorCode: '71' };
};
brew.lock.reviewRequested = new Date();
brew.markModified('lock');
brew.lock.reviewRequested = new Date();
brew.markModified('lock');
await brew.save()
await brew.save()
.catch((error)=>{
throw { name: 'Can Not Set Review Request', message: `Unable to set request for review on brew ID ${req.params.id}`, code: 500, HBErrorCode: '69', error };
});
return res.json({ name: 'Review Requested', message: `Review requested on brew ID ${brew.shareId} - ${brew.title}` });
return res.json({ name: 'Review Requested', message: `Review requested on brew ID ${brew.shareId} - ${brew.title}` });
}));
}));
router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = {
shareId : req.params.id,
'lock.reviewRequested' : { $exists: 1 }
};
const filter = {
shareId : req.params.id,
'lock.reviewRequested' : { $exists: 1 }
};
const brew = await HomebrewModel.findOne(filter);
if(!brew) { throw { name: 'Can Not Clear Review Request', message: `Brew ID ${req.params.id} does not have a review pending!`, HBErrorCode: '73' }; };
const brew = await HomebrewModel.findOne(filter);
if(!brew) { throw { name: 'Can Not Clear Review Request', message: `Brew ID ${req.params.id} does not have a review pending!`, HBErrorCode: '73' }; };
brew.lock.reviewRequested = undefined;
brew.markModified('lock');
brew.lock.reviewRequested = undefined;
brew.markModified('lock');
await brew.save()
await brew.save()
.catch((error)=>{
throw { name: 'Can Not Clear Review Request', message: `Unable to remove request for review on brew ID ${req.params.id}`, HBErrorCode: '72', error };
});
return res.json({ name: 'Review Request Cleared', message: `Review request removed for brew ID ${brew.shareId} - ${brew.title}` });
return res.json({ name: 'Review Request Cleared', message: `Review request removed for brew ID ${brew.shareId} - ${brew.title}` });
}));
}));
// ####################### NOTIFICATIONS
// ####################### NOTIFICATIONS
router.get('/admin/notification/all', async (req, res, next)=>{
try {
const notifications = await NotificationModel.getAll();
return res.json(notifications);
router.get('/admin/notification/all', async (req, res, next)=>{
try {
const notifications = await NotificationModel.getAll();
return res.json(notifications);
} catch (error) {
console.log('Error getting all notifications: ', error.message);
return res.status(500).json({ message: error.message });
}
});
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification);
} catch (error) {
console.log('Error adding notification: ', error.message);
return res.status(500).json({ message: error.message });
}
});
router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.deleteNotification(req.params.id);
return res.json(notification);
} catch (error) {
console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }');
return res.status(500).json({ message: error.message });
}
});
router.get('/admin', mw.adminOnly, (req, res)=>{
templateFn('admin', {
url : req.originalUrl
})
.then((page)=>res.send(page))
.catch((err)=>{
console.log(err);
res.sendStatus(500);
} catch (error) {
console.log('Error getting all notifications: ', error.message);
return res.status(500).json({ message: error.message });
}
});
});
export default router;
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification);
} catch (error) {
console.log('Error adding notification: ', error.message);
return res.status(500).json({ message: error.message });
}
});
router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.deleteNotification(req.params.id);
return res.json(notification);
} catch (error) {
console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }');
return res.status(500).json({ message: error.message });
}
});
router.get('/admin', mw.adminOnly, asyncHandler(async (req, res)=>{
const props = {
url : req.originalUrl
};
const htmlPath = isProd
? path.resolve('build', 'index.html')
: path.resolve('index.html');
let html = fs.readFileSync(htmlPath, 'utf-8');
if(!isProd && vite?.transformIndexHtml) {
html = await vite.transformIndexHtml(req.originalUrl, html);
}
res.send(html.replace(
'<head>',
`<head>\n<script id="props">window.__INITIAL_PROPS__ = ${JSON.stringify(props)}</script>`
));
}));
return router;
}
+167 -223
View File
@@ -1,30 +1,45 @@
/*eslint max-lines: ["warn", {"max": 1000, "skipBlankLines": true, "skipComments": true}]*/
import mongoose from 'mongoose';
import supertest from 'supertest';
import HBApp from './app.js';
import createApp from './app.js';
import { model as NotificationModel } from './notifications.model.js';
import { model as HomebrewModel } from './homebrew.model.js';
let app;
let request;
let dbState;
// Mimic https responses to avoid being redirected all the time
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https');
beforeAll(async ()=>{
app = await createApp();
request = supertest.agent(app).set('X-Forwarded-Proto', 'https');
});
describe('Tests for admin api', ()=>{
beforeEach(()=>{
dbState = mongoose.connection.readyState;
mongoose.connection.readyState = 1;
});
afterEach(()=>{
mongoose.connection.readyState = dbState;
jest.resetAllMocks();
});
afterAll(async ()=>{
await mongoose.connection.close();
});
describe('Notifications', ()=>{
it('should return list of all notifications', async ()=>{
const testNotifications = ['a', 'b'];
jest.spyOn(NotificationModel, 'find')
.mockImplementationOnce(()=>{
jest.spyOn(NotificationModel, 'find').mockImplementationOnce(()=>{
return { exec: jest.fn().mockResolvedValue(testNotifications) };
});
const response = await app
.get('/admin/notification/all')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
const response = await request
.get('/admin/notification/all')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual(testNotifications);
@@ -44,18 +59,17 @@ describe('Tests for admin api', ()=>{
_id : expect.any(String),
createdAt : expect.any(String),
startAt : inputNotification.startAt,
stopAt : inputNotification.stopAt,
stopAt : inputNotification.stopAt
};
jest.spyOn(NotificationModel.prototype, 'save')
.mockImplementationOnce(function() {
return Promise.resolve(this);
});
jest.spyOn(NotificationModel.prototype, 'save').mockImplementationOnce(function () {
return Promise.resolve(this);
});
const response = await app
.post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification);
const response = await request
.post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification);
expect(response.status).toBe(201);
expect(response.body).toEqual(savedNotification);
@@ -69,16 +83,14 @@ describe('Tests for admin api', ()=>{
stopAt : new Date().toISOString()
};
//Change 'save' function to just return itself instead of actually interacting with the database
jest.spyOn(NotificationModel.prototype, 'save')
.mockImplementationOnce(function() {
return Promise.resolve(this);
});
jest.spyOn(NotificationModel.prototype, 'save').mockImplementationOnce(function () {
return Promise.resolve(this);
});
const response = await app
.post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification);
const response = await request
.post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification);
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Dismiss key is required!' });
@@ -87,15 +99,15 @@ describe('Tests for admin api', ()=>{
it('should delete a notification based on its dismiss key', async ()=>{
const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce((key)=>{
return { exec: jest.fn().mockResolvedValue(key) };
});
const response = await app
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
jest.spyOn(NotificationModel, 'findOneAndDelete').mockImplementationOnce((key)=>{
return { exec: jest.fn().mockResolvedValue(key) };
});
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' });
const response = await request
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ dismissKey: 'testKey' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ dismissKey: 'testKey' });
});
@@ -103,15 +115,15 @@ describe('Tests for admin api', ()=>{
it('should handle error deleting a notification that doesnt exist', async ()=>{
const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce(()=>{
return { exec: jest.fn().mockResolvedValue() };
});
const response = await app
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
jest.spyOn(NotificationModel, 'findOneAndDelete').mockImplementationOnce(()=>{
return { exec: jest.fn().mockResolvedValue() };
});
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' });
const response = await request
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ dismissKey: 'testKey' });
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Notification not found' });
});
@@ -120,30 +132,24 @@ describe('Tests for admin api', ()=>{
describe('Locks', ()=>{
describe('Count', ()=>{
it('Count of all locked documents', async ()=>{
const testNumber = 16777216; // 8^8, because why not
const testNumber = 16777216;
jest.spyOn(HomebrewModel, 'countDocuments')
.mockImplementationOnce(()=>{
return Promise.resolve(testNumber);
});
jest.spyOn(HomebrewModel, 'countDocuments').mockImplementationOnce(()=>Promise.resolve(testNumber));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/count');
const response = await request
.get('/api/lock/count')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ count: testNumber });
});
it('Handle error while fetching count of locked documents', async ()=>{
jest.spyOn(HomebrewModel, 'countDocuments')
.mockImplementationOnce(()=>{
return Promise.reject();
});
jest.spyOn(HomebrewModel, 'countDocuments').mockImplementationOnce(()=>Promise.reject());
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/count');
const response = await request
.get('/api/lock/count')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -151,7 +157,7 @@ describe('Tests for admin api', ()=>{
message : 'Unable to get lock count',
name : 'Lock Count Error',
originalUrl : '/api/lock/count',
status : 500,
status : 500
});
});
});
@@ -160,28 +166,22 @@ describe('Tests for admin api', ()=>{
it('Get list of all locked documents', async ()=>{
const testLocks = ['a', 'b'];
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.resolve(testLocks);
});
jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.resolve(testLocks));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/locks');
const response = await request
.get('/api/locks')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ lockedDocuments: testLocks });
});
it('Handle error while fetching list of all locked documents', async ()=>{
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.reject();
});
jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.reject());
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/locks');
const response = await request
.get('/api/locks')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -196,28 +196,22 @@ describe('Tests for admin api', ()=>{
it('Get list of all locked documents with pending review requests', async ()=>{
const testLocks = ['a', 'b'];
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.resolve(testLocks);
});
jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.resolve(testLocks));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/reviews');
const response = await request
.get('/api/lock/reviews')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ reviewDocuments: testLocks });
});
it('Handle error while fetching list of all locked documents with pending review requests', async ()=>{
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.reject();
});
jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.reject());
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/reviews');
const response = await request
.get('/api/lock/reviews')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -235,8 +229,8 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); }
markModified : ()=>true,
save : ()=>Promise.resolve()
};
const testLock = {
@@ -245,15 +239,12 @@ describe('Tests for admin api', ()=>{
shareMessage : 'share'
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.post(`/api/lock/${testBrew.shareId}`)
.send(testLock);
const response = await request
.post(`/api/lock/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
@@ -277,24 +268,21 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : {
code : 1,
editMessage : 'oldEdit',
shareMessage : 'oldShare',
shareMessage : 'oldShare'
}
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.post(`/api/lock/${testBrew.shareId}`)
.send(testLock);
const response = await request
.post(`/api/lock/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
@@ -317,24 +305,21 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : {
code : 1,
editMessage : 'oldEdit',
shareMessage : 'oldShare',
shareMessage : 'oldShare'
}
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.post(`/api/lock/${testBrew.shareId}`)
.send(testLock);
const response = await request
.post(`/api/lock/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -352,8 +337,8 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); }
markModified : ()=>true,
save : ()=>Promise.reject()
};
const testLock = {
@@ -362,15 +347,12 @@ describe('Tests for admin api', ()=>{
shareMessage : 'share'
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.post(`/api/lock/${testBrew.shareId}`)
.send(testLock);
const response = await request
.post(`/api/lock/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -396,19 +378,17 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/unlock/${testBrew.shareId}`);
const response = await request.put(`/api/unlock/${testBrew.shareId}`).set(
'Authorization',
`Basic ${Buffer.from('admin:password3').toString('base64')}`
);
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -421,18 +401,16 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve()
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/unlock/${testBrew.shareId}`);
const response = await request.put(`/api/unlock/${testBrew.shareId}`).set(
'Authorization',
`Basic ${Buffer.from('admin:password3').toString('base64')}`
);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -441,7 +419,7 @@ describe('Tests for admin api', ()=>{
name : 'Not Locked',
originalUrl : `/api/unlock/${testBrew.shareId}`,
shareId : testBrew.shareId,
status : 500,
status : 500
});
});
@@ -456,19 +434,17 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); },
markModified : ()=>true,
save : ()=>Promise.reject(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/unlock/${testBrew.shareId}`);
const response = await request.put(`/api/unlock/${testBrew.shareId}`).set(
'Authorization',
`Basic ${Buffer.from('admin:password3').toString('base64')}`
);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -494,40 +470,28 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`);
const response = await request.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({
message : `Review requested on brew ID ${testBrew.shareId} - ${testBrew.title}`,
name : 'Review Requested',
name : 'Review Requested'
});
});
it('Error when cannot find a locked brew', async ()=>{
const testBrew = {
shareId : 'shareId'
};
const testBrew = { shareId: 'shareId' };
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(false));
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`)
.catch((err)=>{return err;});
const response = await request.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -557,25 +521,20 @@ describe('Tests for admin api', ()=>{
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
.mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`)
.catch((err)=>{return err;});
const response = await request
.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '70',
HBErrorCode : '71',
code : 500,
message : `Cannot find a locked brew with ID ${testBrew.shareId}`,
name : 'Brew Not Found',
message : `Review already requested for brew ${testBrew.shareId} - ${testBrew.title}`,
name : 'Review Already Requested',
originalUrl : `/api/lock/review/request/${testBrew.shareId}`
});
});
it('Handle error while adding review request to a locked brew', async ()=>{
const testLock = {
applied : 'YES',
@@ -587,18 +546,14 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); },
markModified : ()=>true,
save : ()=>Promise.reject(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`);
const response = await request.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -622,19 +577,16 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`);
const response = await request
.put(`/api/lock/review/remove/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -644,18 +596,13 @@ describe('Tests for admin api', ()=>{
});
it('Error when clearing review request from a brew with no review request', async ()=>{
const testBrew = {
shareId : 'shareId',
};
const testBrew = { shareId: 'shareId' };
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(false));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`);
const response = await request
.put(`/api/lock/review/remove/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -678,19 +625,16 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); },
markModified : ()=>true,
save : ()=>Promise.reject(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`);
const response = await request
.put(`/api/lock/review/remove/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
+568 -543
View File
File diff suppressed because it is too large Load Diff
+26 -21
View File
@@ -2,27 +2,32 @@ import _ from 'lodash';
// Default properties for newly-created brews
const DEFAULT_BREW = {
title : '',
text : '',
style : undefined,
description : '',
editId : undefined,
shareId : undefined,
createdAt : undefined,
updatedAt : undefined,
renderer : 'V3',
theme : '5ePHB',
authors : [],
tags : [],
systems : [],
lang : 'en',
thumbnail : '',
views : 0,
published : false,
pageCount : 1,
gDrive : false,
trashed : false
title : '',
text : '',
style : undefined,
description : '',
editId : undefined,
shareId : undefined,
createdAt : undefined,
updatedAt : undefined,
renderer : 'V3',
theme : '5ePHB',
authors : [],
tags : [],
lang : 'en',
thumbnail : '',
views : 0,
published : false,
pageCount : 1,
gDrive : false,
trashed : false,
bleedSize : { top: '.125in', bottom: '.125in', inner: '.125in', outer: '.125in' },
safetySpace : { top: '.25in', bottom: '.25in', outer: '.25in', inner: '.5in' },
trimSize : { width: '8.5in', height: '11in' },
columns : '2',
columnGutter : '.125in',
license : 'None',
legalAuthors : ''
};
// Default values for older brews with missing properties
// e.g., missing "renderer" is assumed to be "legacy"
+15 -2
View File
@@ -22,16 +22,29 @@ const handleConnectionError = (error)=>{
}
};
const addListeners = (conn)=>{
conn.connection.on('disconnecting', ()=>{console.log('Mongo disconnecting...');});
conn.connection.on('disconnected', ()=>{console.log('Mongo disconnected!');});
conn.connection.on('connecting', ()=>{console.log('Mongo connecting...');});
conn.connection.on('connected', ()=>{console.log('Mongo connected!');});
return conn;
};
const disconnect = async ()=>{
return await Mongoose.disconnect();
};
const connect = async (config)=>{
return await Mongoose.connect(getMongoDBURL(config), { retryWrites: false })
.catch((error)=>handleConnectionError(error));
return await Mongoose.connect(getMongoDBURL(config), {
retryWrites : false,
autoIndex : (config.get('local_environments').includes(config.get('node_env')))
})
.then(addListeners(Mongoose))
.catch((error)=>handleConnectionError(error));
};
export default {
connect,
disconnect
};
+66
View File
@@ -0,0 +1,66 @@
import forceSSL from './forcessl.mw';
describe('Tests for ForceSSL middleware', ()=>{
let originalEnv;
let nextFn;
let req = {};
let res = {};
beforeEach(()=>{
originalEnv = process.env.NODE_ENV;
nextFn = jest.fn();
req = {
header : ()=>{ return 'http'; },
get : ()=>{ return 'test'; },
url : 'URL'
};
res = {
redirect : jest.fn()
};
});
afterEach(()=>{
process.env.NODE_ENV = originalEnv;
jest.clearAllMocks();
});
it('should not redirect when NODE_ENV is set to local', ()=>{
process.env.NODE_ENV = 'local';
forceSSL(null, null, nextFn);
expect(res.redirect).not.toHaveBeenCalled();
expect(nextFn).toHaveBeenCalled();
});
it('should not redirect when NODE_ENV is set to docker', ()=>{
process.env.NODE_ENV = 'docker';
forceSSL(null, null, nextFn);
expect(res.redirect).not.toHaveBeenCalled();
expect(nextFn).toHaveBeenCalled();
});
it('should redirect with 302 when header is not HTTPS and NODE_ENV is not local or docker', ()=>{
process.env.NODE_ENV = 'test';
forceSSL(req, res, nextFn);
expect(res.redirect).toHaveBeenCalledWith(302, 'https://testURL');
expect(nextFn).not.toHaveBeenCalled();
});
it('should not redirect when header is HTTPS and NODE_ENV is not local or docker', ()=>{
process.env.NODE_ENV = 'test';
req.header = ()=>{ return 'https'; };
forceSSL(req, res, nextFn);
expect(res.redirect).not.toHaveBeenCalled();
expect(nextFn).toHaveBeenCalled();
});
});
+23 -4
View File
@@ -6,6 +6,7 @@ import config from './config.js';
let serviceAuth;
let clientEmail;
if(!config.get('service_account')){
const reset = '\x1b[0m'; // Reset to default style
const yellow = '\x1b[33m'; // yellow color
@@ -15,6 +16,10 @@ if(!config.get('service_account')){
JSON.parse(config.get('service_account')) :
config.get('service_account');
if(keys?.client_email) {
clientEmail = keys.client_email;
}
try {
serviceAuth = googleDrive.auth.fromJSON(keys);
serviceAuth.scopes = ['https://www.googleapis.com/auth/drive'];
@@ -146,7 +151,6 @@ const GoogleActions = {
description : file.description,
views : parseInt(file.properties.views),
published : file.properties.published ? file.properties.published == 'true' : false,
systems : [],
lang : file.properties.lang,
thumbnail : file.properties.thumbnail,
webViewLink : file.webViewLink
@@ -227,14 +231,30 @@ const GoogleActions = {
if(!obj) return;
if(clientEmail) {
await drive.permissions.create({
resource : {
type : 'user',
emailAddress : clientEmail,
role : 'writer'
},
fileId : obj.data.id,
fields : 'id',
})
.catch((err)=>{
console.log('Error adding Service Account permissions on Google Drive file');
console.error(err);
});
}
await drive.permissions.create({
resource : { type : 'anyone',
role : 'writer' },
role : 'writer' },
fileId : obj.data.id,
fields : 'id',
})
.catch((err)=>{
console.log('Error updating permissions');
console.log('Error adding "Anyone" permissions on Google Drive file');
console.error(err);
});
@@ -277,7 +297,6 @@ const GoogleActions = {
text : file.data,
description : obj.data.description,
systems : obj.data.properties.systems ? obj.data.properties.systems.split(',') : [],
authors : [],
lang : obj.data.properties.lang,
published : obj.data.properties.published ? obj.data.properties.published == 'true' : false,
+112 -20
View File
@@ -4,13 +4,16 @@ import { model as HomebrewModel } from './homebrew.model.js';
import express from 'express';
import zlib from 'zlib';
import GoogleActions from './googleActions.js';
import Markdown from '../shared/naturalcrit/markdown.js';
import yaml from 'js-yaml';
import { hbfm } from 'hbmarkedwrapper';
import * as yaml from 'js-yaml';
import asyncHandler from 'express-async-handler';
import { nanoid } from 'nanoid';
import { splitTextStyleAndMetadata,
brewSnippetsToJSON } from '../shared/helpers.js';
import { makePatches, applyPatches, stringifyPatches, parsePatch } from '@sanity/diff-match-patch';
import { md5 } from 'hash-wasm';
import { splitTextStyleAndMetadata,
brewSnippetsToJSON, debugTextMismatch } from '../shared/helpers.js';
import checkClientVersion from './middleware/check-client-version.js';
import dbCheck from './middleware/dbCheck.js';
const router = express.Router();
@@ -30,6 +33,27 @@ const isStaticTheme = (renderer, themeName)=>{
// });
// };
const migrateSystemsToTags = (brew)=>{
if(!('systems' in brew)) return brew;
if(!Array.isArray(brew.systems) || brew.systems.length === 0) {
brew.systems = undefined;
return brew;
}
const systemMap = {
'5e' : 'system:D&D 5e',
'4e' : 'system:D&D 4e',
'3.5e' : 'system:D&D 3.5e',
'Pathfinder' : 'system:Pathfinder 2e'
};
const systemTags = brew.systems.map((s)=>systemMap[s]);
brew.tags = _.uniq([...(brew.tags || []), ...systemTags]);
brew.systems = undefined;
return brew;
};
const MAX_TITLE_LENGTH = 100;
const api = {
@@ -48,6 +72,20 @@ const api = {
}
id = id.slice(googleId.length);
}
// ID Validation Checks
// Homebrewery ID
// Typically 12 characters, but the DB shows a range of 7 to 14 characters
if(!id.match(/^[a-zA-Z0-9-_]{7,14}$/)){
throw { name: 'ID Error', message: 'Invalid ID', status: 404, HBErrorCode: '11', brewId: id };
}
// Google ID
// Typically 33 characters, old format is 44 - always starts with a 1
// Managed by Google, may change outside of our control, so any length between 33 and 44 is acceptable
if(googleId && !googleId.match(/^1(?:[a-zA-Z0-9-_]{32,43})$/)){
throw { name: 'Google ID Error', message: 'Invalid ID', status: 404, HBErrorCode: '12', brewId: id };
}
return { id, googleId };
},
//Get array of any of this user's brews tagged with `meta:theme`
@@ -152,11 +190,13 @@ const api = {
stub.renderer = stub.renderer || undefined; // Clear empty strings
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
req.brew = stub;
const fixedStub = migrateSystemsToTags(stub);
req.brew = fixedStub;
next();
};
},
getCSS : async (req, res)=>{
const { brew } = req;
if(!brew) return res.status(404).send('');
@@ -169,7 +209,6 @@ const api = {
});
return res.status(200).send(brew.style);
},
mergeBrewText : (brew)=>{
let text = brew.text;
if(brew.style !== undefined) {
@@ -178,18 +217,25 @@ const api = {
`\`\`\`\n\n` +
`${text}`;
}
const metadata = _.pick(brew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme']);
const metadata = _.pick(brew, ['title', 'description', 'tags', 'renderer', 'theme']);
const snippetsArray = brewSnippetsToJSON('brew_snippets', brew.snippets, null, false).snippets;
metadata.snippets = snippetsArray.length > 0 ? snippetsArray : undefined;
metadata.bleedSize = { top: brew?.bleedSize?.top, bottom: brew?.bleedSize?.bottom, inner: brew?.bleedSize?.inner, outer: brew?.bleedSize?.outer };
metadata.safetySpace = { top: brew?.safetySpace?.top, bottom: brew?.safetySpace?.bottom, outer: brew?.safetySpace?.outer, inner: brew?.safetySpace?.inner };
metadata.trimSize = { width: brew?.trimSize?.width, height: brew?.trimSize?.height };
metadata.columns = brew?.columns;
metadata.columnGutter = brew?.columnGutter;
metadata.license = brew?.license;
metadata.legalAuthors = brew?.legalAuthors;
text = `\`\`\`metadata\n` +
`${yaml.dump(metadata)}\n` +
`\`\`\`\n\n` +
`${text}`;
return text;
},
getGoodBrewTitle : (text)=>{
const tokens = Markdown.marked.lexer(text);
const tokens = hbfm.marked.lexer(text);
return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title')
.slice(0, MAX_TITLE_LENGTH);
},
@@ -339,21 +385,62 @@ const api = {
// Initialize brew from request and body, destructure query params, and set the initial value for the after-save method
const brewFromClient = api.excludePropsFromUpdate(req.body);
const brewFromServer = req.brew;
if(brewFromServer.version && brewFromClient.version && brewFromServer.version > brewFromClient.version) {
splitTextStyleAndMetadata(brewFromServer);
if(brewFromServer?.version !== brewFromClient?.version){
console.log(`Version mismatch on brew ${brewFromClient.editId}`);
res.setHeader('Content-Type', 'application/json');
return res.status(409).send(JSON.stringify({ message: `The brew has been changed on a different device. Please save your changes elsewhere, refresh, and try again.` }));
return res.status(409).send(JSON.stringify({ message: `The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` }));
}
let brew = _.assign(brewFromServer, brewFromClient);
brewFromServer.text = brewFromServer.text.normalize('NFC');
brewFromServer.hash = await md5(brewFromServer.text);
if(brewFromServer?.hash !== brewFromClient?.hash) {
console.log(`Hash mismatch on brew ${brewFromClient.editId}`);
debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
res.setHeader('Content-Type', 'application/json');
return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` }));
}
let result = [];
try {
const patches = parsePatch(brewFromClient.patches);
// Patch to a throwaway variable while parallelizing - we're more concerned with error/no error.
result = applyPatches(patches, encodeURI(brewFromServer.text));
const failedPatches = patches.map((patch, index)=>{if(!result[1][index]){ return patch; }});
if(failedPatches > 0){
throw (`Patch failure: ${failedPatches}/${result[1].length} did not apply`);
}
if(decodeURI(result[0]) != brewFromClient.text){
throw ('Patches did not apply cleanly, text mismatch detected');
}
// brew.text = applyPatches(patches, brewFromServer.text)[0];
} catch (err) {
debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
console.error('Failed to apply patches:', {
// patches : brewFromClient.patches,
// result : result,
brewId : brewFromClient.editId || 'unknown',
error : err
});
// While running in parallel, don't throw the error upstream.
// throw err; // rethrow to preserve the 500 behavior
}
let brew = _.assign(brewFromServer, brewFromClient);
migrateSystemsToTags(brew);
brew.title = brew.title.trim();
brew.description = brew.description.trim() || '';
brew.text = api.mergeBrewText(brew);
const googleId = brew.googleId;
const { saveToGoogle, removeFromGoogle } = req.query;
let afterSave = async ()=>true;
brew.title = brew.title.trim();
brew.description = brew.description.trim() || '';
brew.text = api.mergeBrewText(brew);
if(brew.googleId && removeFromGoogle) {
// If the google id exists and we're removing it from google, set afterSave to delete the google brew and mark the brew's google id as undefined
afterSave = async ()=>{
@@ -414,6 +501,8 @@ const api = {
const after = await afterSave();
if(!after) return;
saved.textBin = undefined; // Remove textBin from the saved object to save bandwidth
Stream.emit('sendUpdate', 'brewUpdated', { time: new Date, shareId: brew.shareId, version: brew.version });
res.status(200).send(saved);
@@ -435,6 +524,7 @@ const api = {
await HomebrewModel.deleteOne({ editId: id });
return next();
}
throw (err);
}
let brew = req.brew;
@@ -485,11 +575,13 @@ const api = {
}
};
router.use(dbCheck);
router.post('/api', checkClientVersion, asyncHandler(api.newBrew));
router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew));
router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew));
router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
export default api;
export default api;
+457 -136
View File
@@ -63,7 +63,6 @@ describe('Tests for api', ()=>{
title : 'some title',
description : 'this is a description',
tags : ['something', 'fun'],
systems : ['D&D 5e'],
lang : 'en',
renderer : 'v3',
theme : 'phb',
@@ -99,18 +98,87 @@ describe('Tests for api', ()=>{
expect(googleId).toBeUndefined();
});
it('should throw if id is too short', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcd'
}
});
} catch (e) {
err = e;
};
expect(err).toEqual({ HBErrorCode: '11', brewId: 'abcd', message: 'Invalid ID', name: 'ID Error', status: 404 });
});
it('should return id and google id from request body', ()=>{
const { id, googleId } = api.getId({
params : {
id : 'abcdefgh'
id : 'abcdefghijkl'
},
body : {
googleId : '12345'
googleId : '123456789012345678901234567890123'
}
});
expect(id).toEqual('abcdefgh');
expect(googleId).toEqual('12345');
expect(id).toEqual('abcdefghijkl');
expect(googleId).toEqual('123456789012345678901234567890123');
});
it('should throw invalid - google id right length but does not match pattern', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcdefghijkl'
},
body : {
googleId : '012345678901234567890123456789012'
}
});
} catch (e) {
err = e;
}
expect(err).toEqual({ HBErrorCode: '12', brewId: 'abcdefghijkl', message: 'Invalid ID', name: 'Google ID Error', status: 404 });
});
it('should throw invalid - google id too short (32 char)', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcdefghijkl'
},
body : {
googleId : '12345678901234567890123456789012'
}
});
} catch (e) {
err = e;
}
expect(err).toEqual({ HBErrorCode: '12', brewId: 'abcdefghijkl', message: 'Invalid ID', name: 'Google ID Error', status: 404 });
});
it('should throw invalid - google id too long (45 char)', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcdefghijkl'
},
body : {
googleId : '123456789012345678901234567890123456789012345'
}
});
} catch (e) {
err = e;
}
expect(err).toEqual({ HBErrorCode: '12', brewId: 'abcdefghijkl', message: 'Invalid ID', name: 'Google ID Error', status: 404 });
});
it('should return 12-char id and google id from params', ()=>{
@@ -135,7 +203,6 @@ describe('Tests for api', ()=>{
expect(id).toEqual('abcdefghij');
});
});
describe('getBrew', ()=>{
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
const notFoundError = { HBErrorCode: '05', message: 'Brew not found', name: 'BrewLoad Error', status: 404, accessType: 'share', brewId: '1' };
@@ -282,7 +349,6 @@ describe('Tests for api', ()=>{
renderer : 'legacy',
lang : 'en',
shareId : undefined,
systems : [],
tags : [],
theme : '5ePHB',
thumbnail : '',
@@ -293,7 +359,27 @@ describe('Tests for api', ()=>{
style : undefined,
trashed : false,
updatedAt : undefined,
views : 0
views : 0,
bleedSize: {
top: '.125in',
bottom: '.125in',
inner: '.125in',
outer: '.125in',
},
columns: '2',
columnGutter: '.125in',
legalAuthors: '',
license: 'None',
safetySpace: {
top: '.25in',
bottom: '.25in',
outer: '.25in',
inner: '.5in',
},
trimSize: {
width: '8.5in',
height: '11in',
},
});
expect(next).toHaveBeenCalled();
expect(api.getId).toHaveBeenCalledWith(req);
@@ -313,7 +399,68 @@ describe('Tests for api', ()=>{
await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '51', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' });
});
});
describe('Get CSS', ()=>{
it('should return brew style content as CSS text', async ()=>{
const testBrew = { title: 'test brew', text: '```css\n\nI Have a style!\n```\n\n' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n');
expect(res.set).toHaveBeenCalledWith({
'Cache-Control' : 'no-cache',
'Content-Type' : 'text/css'
});
});
it('should return 404 when brew has no style content', async ()=>{
const testBrew = { title: 'test brew', text: 'I don\'t have a style!' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
it('should return 404 when brew does not exist', async ()=>{
const testBrew = { };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
});
describe('mergeBrewText', ()=>{
it('should set metadata and no style if it is not present', ()=>{
const result = api.mergeBrewText({
@@ -321,10 +468,29 @@ describe('Tests for api', ()=>{
title : 'some title',
description : 'this is a description',
tags : ['something', 'fun'],
systems : ['D&D 5e'],
renderer : 'v3',
theme : 'phb',
googleId : '12345'
googleId : '12345',
bleedSize: {
top: '.125in',
bottom: '.125in',
inner: '.125in',
outer: '.125in',
},
columns: '2',
columnGutter: '.125in',
legalAuthors: '',
license: 'None',
safetySpace: {
top: '.25in',
bottom: '.25in',
outer: '.25in',
inner: '.5in',
},
trimSize: {
width: '8.5in',
height: '11in',
},
});
expect(result).toEqual(`\`\`\`metadata
@@ -333,10 +499,25 @@ description: this is a description
tags:
- something
- fun
systems:
- D&D 5e
renderer: v3
theme: phb
bleedSize:
top: .125in
bottom: .125in
inner: .125in
outer: .125in
safetySpace:
top: .25in
bottom: .25in
outer: .25in
inner: .5in
trimSize:
width: 8.5in
height: 11in
columns: '2'
columnGutter: .125in
license: None
legalAuthors: ''
\`\`\`
@@ -350,10 +531,29 @@ brew`);
title : 'some title',
description : 'this is a description',
tags : ['something', 'fun'],
systems : ['D&D 5e'],
renderer : 'v3',
theme : 'phb',
googleId : '12345'
googleId : '12345',
bleedSize: {
top: '.125in',
bottom: '.125in',
inner: '.125in',
outer: '.125in',
},
columns: '2',
columnGutter: '.125in',
legalAuthors: '',
license: 'None',
safetySpace: {
top: '.25in',
bottom: '.25in',
outer: '.25in',
inner: '.5in',
},
trimSize: {
width: '8.5in',
height: '11in',
},
});
expect(result).toEqual(`\`\`\`metadata
@@ -362,10 +562,25 @@ description: this is a description
tags:
- something
- fun
systems:
- D&D 5e
renderer: v3
theme: phb
bleedSize:
top: .125in
bottom: .125in
inner: .125in
outer: .125in
safetySpace:
top: .25in
bottom: .25in
outer: .25in
inner: .5in
trimSize:
width: 8.5in
height: 11in
columns: '2'
columnGutter: .125in
license: None
legalAuthors: ''
\`\`\`
@@ -376,7 +591,6 @@ hello yes i am css
brew`);
});
});
describe('exclusion methods', ()=>{
it('excludePropsFromUpdate removes the correct keys', ()=>{
const sent = Object.assign({}, googleBrew);
@@ -394,7 +608,6 @@ brew`);
expect(sent).toEqual(googleBrew);
expect(result.tags).toBeUndefined();
expect(result.systems).toBeUndefined();
expect(result.published).toBeUndefined();
expect(result.authors).toBeUndefined();
expect(result.owner).toBeUndefined();
@@ -414,7 +627,6 @@ brew`);
expect(result.pageCount).toBe(1);
});
});
describe('beforeNewSave', ()=>{
it('sets the title if none', ()=>{
const brew = {
@@ -456,7 +668,6 @@ brew`);
expect(hbBrew.text).toEqual('merged');
});
});
describe('newGoogleBrew', ()=>{
it('should call the correct methods', ()=>{
api.excludeGoogleProps = jest.fn(()=>'newBrew');
@@ -470,26 +681,24 @@ brew`);
expect(google.newGoogleBrew).toHaveBeenCalledWith('client', 'newBrew');
});
});
describe('newBrew', ()=>{
it('should set up a default brew via Homebrew model', async ()=>{
await api.newBrew({ body: { text: 'asdf' }, query: {}, account: { username: 'test user' } }, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
_id : '1',
authors : ['test user'],
createdAt : undefined,
description : '',
editId : expect.any(String),
gDrive : false,
pageCount : 1,
published : false,
renderer : 'V3',
lang : 'en',
_id : '1',
authors : ['test user'],
createdAt : undefined,
description : '',
editId : expect.any(String),
gDrive : false,
pageCount : 1,
published : false,
renderer : 'V3',
lang : 'en',
shareId : expect.any(String),
style : undefined,
systems : [],
tags : [],
text : undefined,
textBin : expect.objectContaining({}),
@@ -498,7 +707,27 @@ brew`);
title : 'asdf',
trashed : false,
updatedAt : undefined,
views : 0
views : 0,
bleedSize: {
top: '.125in',
bottom: '.125in',
inner: '.125in',
outer: '.125in',
},
columns: '2',
columnGutter: '.125in',
legalAuthors: '',
license: 'None',
safetySpace: {
top: '.25in',
bottom: '.25in',
outer: '.25in',
inner: '.5in',
},
trimSize: {
width: '8.5in',
height: '11in',
},
});
});
@@ -536,43 +765,51 @@ brew`);
expect(google.newGoogleBrew).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
_id : '1',
authors : ['test user'],
createdAt : undefined,
description : '',
editId : expect.any(String),
gDrive : false,
pageCount : 1,
published : false,
renderer : 'V3',
lang : 'en',
shareId : expect.any(String),
googleId : expect.any(String),
style : undefined,
systems : [],
tags : [],
text : undefined,
textBin : undefined,
theme : '5ePHB',
thumbnail : '',
title : 'asdf',
trashed : false,
updatedAt : undefined,
views : 0
_id : '1',
authors : ['test user'],
bleedSize: {
top: '.125in',
bottom: '.125in',
inner: '.125in',
outer: '.125in',
},
columns: '2',
columnGutter: '.125in',
createdAt : undefined,
description : '',
editId : expect.any(String),
gDrive : false,
pageCount : 1,
published : false,
renderer : 'V3',
lang : 'en',
shareId : expect.any(String),
legalAuthors: '',
license: 'None',
safetySpace: {
top: '.25in',
bottom: '.25in',
outer: '.25in',
inner: '.5in',
},
googleId : expect.any(String),
style : undefined,
tags : [],
text : undefined,
textBin : undefined,
theme : '5ePHB',
thumbnail : '',
title : 'asdf',
trashed : false,
trimSize: {
width: '8.5in',
height: '11in',
},
updatedAt : undefined,
views : 0
});
});
});
describe('deleteGoogleBrew', ()=>{
it('should check auth and delete brew', async ()=>{
const result = await api.deleteGoogleBrew({ username: 'test user' }, 'id', 'editId', res);
expect(result).toBe(true);
expect(google.authCheck).toHaveBeenCalledWith({ username: 'test user' }, expect.objectContaining({}));
expect(google.deleteGoogleBrew).toHaveBeenCalledWith('client', 'id', 'editId');
});
});
describe('Theme bundle', ()=>{
it('should return Theme Bundle for a User Theme', async ()=>{
const brews = {
@@ -716,7 +953,94 @@ brew`);
status : 422 });
});
});
describe('updateBrew', ()=>{
it('should return error on version mismatch', async ()=>{
const brewFromClient = { version: 1 };
const brewFromServer = { version: 1000, text: '' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
it('should return error on hash mismatch', async ()=>{
const brewFromClient = { version: 1, hash: '1234' };
const brewFromServer = { version: 1, text: 'test' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(req.brew.hash).toBe('098f6bcd4621d373cade4e832627b4f6');
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
// Commenting this one out for now, since we are no longer throwing this error while we monitor
// it('should return error on applying patches', async ()=>{
// const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: 'not a valid patch string' };
// const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
// const req = {
// brew : brewFromServer,
// body : brewFromClient,
// };
// let err;
// try {
// await api.updateBrew(req, res);
// } catch (e) {
// err = e;
// }
// expect(err).toEqual(Error('Invalid patch string: not a valid patch string'));
// });
it('should save brew, no ID', async ()=>{
const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: '' };
const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
model.save = jest.fn((brew)=>{return brew;});
const req = {
brew : brewFromServer,
body : brewFromClient,
query : { saveToGoogle: false, removeFromGoogle: false }
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({
_id : '1',
description : 'Test Description',
hash : '098f6bcd4621d373cade4e832627b4f6',
title : 'Test Title',
version : 2
})
);
});
});
describe('deleteGoogleBrew', ()=>{
it('should check auth and delete brew', async ()=>{
const result = await api.deleteGoogleBrew({ username: 'test user' }, 'id', 'editId', res);
expect(result).toBe(true);
expect(google.authCheck).toHaveBeenCalledWith({ username: 'test user' }, expect.objectContaining({}));
expect(google.deleteGoogleBrew).toHaveBeenCalledWith('client', 'id', 'editId');
});
});
describe('deleteBrew', ()=>{
it('should handle case where fetching the brew returns an error', async ()=>{
api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; });
@@ -937,68 +1261,7 @@ brew`);
expect(saved.googleId).toEqual(brew.googleId);
});
});
describe('Get CSS', ()=>{
it('should return brew style content as CSS text', async ()=>{
const testBrew = { title: 'test brew', text: '```css\n\nI Have a style!\n```\n\n' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n');
expect(res.set).toHaveBeenCalledWith({
'Cache-Control' : 'no-cache',
'Content-Type' : 'text/css'
});
});
it('should return 404 when brew has no style content', async ()=>{
const testBrew = { title: 'test brew', text: 'I don\'t have a style!' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
it('should return 404 when brew does not exist', async ()=>{
const testBrew = { };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
});
describe('Split Text, Style, and Metadata', ()=>{
it('basic splitting', async ()=>{
@@ -1007,7 +1270,6 @@ brew`);
'title: title\n' +
'description: description\n' +
'tags: [ \'tag a\' , \'tag b\' ]\n' +
'systems: [ test system ]\n' +
'renderer: legacy\n' +
'theme: 5ePHB\n' +
'lang: en\n' +
@@ -1028,8 +1290,6 @@ brew`);
// Metadata
expect(testBrew.title).toEqual('title');
expect(testBrew.description).toEqual('description');
expect(testBrew.tags).toEqual(['tag a', 'tag b']);
expect(testBrew.systems).toEqual(['test system']);
expect(testBrew.renderer).toEqual('legacy');
expect(testBrew.theme).toEqual('5ePHB');
expect(testBrew.lang).toEqual('en');
@@ -1039,17 +1299,78 @@ brew`);
expect(testBrew.text).toEqual('text\n');
});
it('convert tags string to array', async ()=>{
it('extended metadata', async ()=>{
const testBrew = {
text : '```metadata\n' +
'tags: tag a\n' +
'```\n\n'
'title: title\n' +
'description: description\n' +
'tags: [ \'tag a\' , \'tag b\' ]\n' +
'renderer: legacy\n' +
'theme: 5ePHB\n' +
'lang: en\n' +
'bleedSize:\n' +
' top: 1.5in\n' +
' bottom: 1.5in\n' +
' outer: 1.5in\n' +
' inner: 1.5in\n' +
'safetySpace:\n' +
' top: 1.25in\n' +
' bottom: 1.25in\n' +
' outer: 1.25in\n' +
' inner: 1.5in\n' +
'trimSize:\n' +
' width: 18.5in\n' +
' height: 111in\n' +
'columns: 12\n' +
'columnGutter: 1.125in\n' +
'license: AELF\n' +
'legalAuthors: Tom Bombadil\n' +
'\n' +
'```\n' +
'\n' +
'```css\n' +
'style\n' +
'style\n' +
'style\n' +
'```\n' +
'\n' +
'text\n'
};
splitTextStyleAndMetadata(testBrew);
// Metadata
expect(testBrew.tags).toEqual(['tag a']);
expect(testBrew.title).toEqual('title');
expect(testBrew.description).toEqual('description');
expect(testBrew.renderer).toEqual('legacy');
expect(testBrew.theme).toEqual('5ePHB');
expect(testBrew.lang).toEqual('en');
// Paper Specfications
expect(testBrew.bleedSize.top).toEqual('1.5in');
expect(testBrew.bleedSize.bottom).toEqual('1.5in');
expect(testBrew.bleedSize.inner).toEqual('1.5in');
expect(testBrew.bleedSize.outer).toEqual('1.5in');
expect(testBrew.safetySpace.top).toEqual('1.25in');
expect(testBrew.safetySpace.bottom).toEqual('1.25in');
expect(testBrew.safetySpace.inner).toEqual('1.5in');
expect(testBrew.safetySpace.outer).toEqual('1.25in');
expect(testBrew.trimSize.width).toEqual('18.5in');
expect(testBrew.trimSize.height).toEqual('111in');
expect(testBrew.columns).toEqual(12);
expect(testBrew.columnGutter).toEqual('1.125in');
// Extended Metadata
expect(testBrew.license).toEqual('AELF');
expect(testBrew.legalAuthors).toEqual('Tom Bombadil');
// Style
expect(testBrew.style).toEqual('style\nstyle\nstyle\n');
// Text
expect(testBrew.text).toEqual('text\n');
});
});
});
+26 -15
View File
@@ -7,29 +7,29 @@ import zlib from 'zlib';
const HomebrewSchema = mongoose.Schema({
shareId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
editId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
googleId : { type: String },
title : { type: String, default: '' },
googleId : { type: String, index: true },
title : { type: String, default: '', index: true },
text : { type: String, default: '' },
textBin : { type: Buffer },
pageCount : { type: Number, default: 1 },
pageCount : { type: Number, default: 1, index: true },
description : { type: String, default: '' },
tags : [String],
systems : [String],
lang : { type: String, default: 'en' },
renderer : { type: String, default: '' },
authors : [String],
tags : { type: [String], index: true },
systems : { type: [String], default: undefined },
lang : { type: String, default: 'en', index: true },
renderer : { type: String, default: '', index: true },
authors : { type: [String], index: true },
invitedAuthors : [String],
published : { type: Boolean, default: false },
thumbnail : { type: String, default: '' },
published : { type: Boolean, default: false, index: true },
thumbnail : { type: String, default: '', index: true },
createdAt : { type: Date, default: Date.now },
updatedAt : { type: Date, default: Date.now },
lastViewed : { type: Date, default: Date.now },
createdAt : { type: Date, default: Date.now, index: true },
updatedAt : { type: Date, default: Date.now, index: true },
lastViewed : { type: Date, default: Date.now, index: true },
views : { type: Number, default: 0 },
version : { type: Number, default: 1 },
version : { type: Number, default: 1, index: true },
lock : { type: Object }
lock : { type: Object, index: true }
}, { versionKey: false });
HomebrewSchema.statics.increaseView = async function(query) {
@@ -43,6 +43,8 @@ HomebrewSchema.statics.increaseView = async function(query) {
return brew;
};
// STATIC FUNCTIONS
HomebrewSchema.statics.get = async function(query, fields=null){
const brew = await Homebrew.findOne(query, fields).orFail()
.catch((error)=>{throw 'Can not find brew';});
@@ -63,6 +65,15 @@ HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, f
return brews;
};
// INDEXES
HomebrewSchema.index({ updatedAt: -1, lastViewed: -1 });
HomebrewSchema.index({ published: 1, title: 'text' });
HomebrewSchema.index({ lock: 1, sparse: true });
HomebrewSchema.path('lock.reviewRequested').index({ sparse: true });
const Homebrew = mongoose.model('Homebrew', HomebrewSchema);
export {
+15
View File
@@ -0,0 +1,15 @@
import mongoose from 'mongoose';
import config from '../config.js';
export default (req, res, next)=>{
// Bypass DB checks during testing
if(config.get('node_env') == 'test') return next();
if(mongoose.connection.readyState == 1) return next();
throw {
HBErrorCode : '13',
name : 'Database Connection Error',
message : 'Unable to connect to database',
status : mongoose.connection.readyState
};
};
+28
View File
@@ -0,0 +1,28 @@
import mongoose from 'mongoose';
import dbCheck from './dbCheck.js';
import config from '../config.js';
describe('dbCheck middleware', ()=>{
const next = jest.fn();
afterEach(()=>jest.clearAllMocks());
it('should skip check in test mode', ()=>{
config.get = jest.fn(()=>'test');
expect(()=>dbCheck({}, {}, next)).not.toThrow();
expect(next).toHaveBeenCalled();
});
it('should call next if readyState == 1', ()=>{
config.get = jest.fn(()=>'production');
mongoose.connection.readyState = 1;
dbCheck({}, {}, next);
expect(next).toHaveBeenCalled();
});
it('should throw if readyState != 1', ()=>{
config.get = jest.fn(()=>'production');
mongoose.connection.readyState = 99;
expect(()=>dbCheck({}, {}, next)).toThrow(/Unable to connect/);
});
});
+6 -11
View File
@@ -5,21 +5,16 @@ import config from './config.js';
const generateAccessToken = (account)=>{
const payload = account;
// When the token was issued
payload.issued = (new Date());
// Which service issued the Token
payload.issuer = config.get('authentication_token_issuer');
// Which service is the token intended for
payload.audience = config.get('authentication_token_audience');
// The signing key for signing the token
payload.issued = (new Date()); // When the token was issued
payload.issuer = config.get('authentication_token_issuer'); // Which service issued the Token
payload.audience = config.get('authentication_token_audience'); // Which service is the token intended for
const secret = config.get('authentication_token_secret'); // The signing key for signing the token
delete payload.password;
delete payload._id;
const secret = config.get('authentication_token_secret');
const token = jwt.encode(payload, secret);
return token;
};
export default generateAccessToken;
export default generateAccessToken;
+27
View File
@@ -0,0 +1,27 @@
import { expect, jest } from '@jest/globals';
import config from './config.js';
import generateAccessToken from './token';
describe('Tests for Token', ()=>{
it('Get token', ()=>{
// Mock the Config module, so we aren't grabbing actual secrets for testing
jest.mock('./config.js');
config.get = jest.fn((param)=>{
// The requested key name will be reflected to the output
return param;
});
const account = {};
const token = generateAccessToken(account);
// If these tests fail, the config mock has failed
expect(account).toHaveProperty('issuer', 'authentication_token_issuer');
expect(account).toHaveProperty('audience', 'authentication_token_audience');
// Because the inputs are fixed, this JWT key should be static
expect(typeof token).toBe('string');
});
});