diff --git a/package.json b/package.json index 488ed6d..cbbee0b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "express": "^4.17.1", "express-minify": "^1.0.0", "i18n": "^0.8.3", + "jsdom": "^16.0.0", "kill-port": "^1.6.0", "knex": "^0.19.4", "lighthouse": "^5.2.0", diff --git a/src/Routes/Authentication.js b/src/Routes/Authentication.js index 36adb0c..d9c0b33 100644 --- a/src/Routes/Authentication.js +++ b/src/Routes/Authentication.js @@ -68,7 +68,6 @@ class AuthenticationRoute extends BaseRoute { req.session = null; res.redirect('/'); }); - } get getRouter() { diff --git a/src/Routes/Index.js b/src/Routes/Index.js index fce7607..74a58a6 100644 --- a/src/Routes/Index.js +++ b/src/Routes/Index.js @@ -133,7 +133,6 @@ class IndexRoute extends BaseRoute { }); }); - this.router.get('/sitemap', (req, res) => { sitemap.get(this.db).then(data => { sitemap.save(data).then(() => { diff --git a/src/Structure/BaseRoute.js b/src/Structure/BaseRoute.js index 4ec7c19..5cb62f6 100644 --- a/src/Structure/BaseRoute.js +++ b/src/Structure/BaseRoute.js @@ -10,12 +10,14 @@ class BaseRoute { isMod(req, res, next) { if (req.session.user.mod || req.session.user.admin) return next(); - res.render('error', { title: 'Page not found', status: 404, message: 'The page you were looking for could not be found.' }); + // res.render('error', { title: 'Page not found', status: 404, message: 'The page you were looking for could not be found.' }); + res.status(403).render('authRequired', { title: 'Authentication is required' }); } isAdmin(req, res, next) { if (req.session.user.admin) return next(); - res.render('error', { title: 'Page not found', status: 404, message: 'The page you were looking for could not be found.' }); + // res.render('error', { title: 'Page not found', status: 404, message: 'The page you were looking for could not be found.' }); + res.status(403).render('authRequired', { title: 'Authentication is required' }); } } diff --git a/src/Website.js b/src/Website.js index 77a0fe7..adfe050 100644 --- a/src/Website.js +++ b/src/Website.js @@ -38,13 +38,39 @@ class Website { this.app.use('/assets', express.static(path.join(__dirname, 'Assets'))); this.app.use('/codemirror', express.static(path.join(__dirname, '..', 'node_modules', 'codemirror'))); this.app.use((req, res, next) => { + // Test suite logic + res.locals.adblock = req.headers['x-disable-adsense'] === config.secret; + if (req.headers['x-auth-as-user'] === config.secret || + req.headers['x-auth-as-admin'] === config.secret || + req.headers['x-auth-as-mod'] === config.secret) { + req.session.user = { + id: '123456789012345678', + username: 'User', + avatar: '', + discriminator: '1234', + locale: 'en-US', + mfa_enabled: false, + flags: 0, + access_token: '', + expires_in: 604800, + refresh_token: '', + scope: 'identify', + token_type: 'Bearer', + admin: false, + mod: false + }; + if (req.headers['x-auth-as-mod'] || req.headers['x-auth-as-admin']) req.session.user.mod = true; + if (req.headers['x-auth-as-admin']) req.session.user.admin = true; + } + if (req.headers['x-auth-as-anon'] === config.secret) req.session.user = undefined; + + // App locals const host = req.get('host'); res.locals.route = req.connection.encrypted ? 'https://' : 'http://' + host + req.path; res.locals.isProduction = host.toLowerCase().trim() === 'botblock.org'; res.locals.isStaging = host.toLowerCase().trim() === 'staging.botblock.org'; res.locals.isDevelopment = !res.locals.isProduction && !res.locals.isStaging; res.locals.language = req.cookies.lang; - res.locals.adblock = req.headers['x-disable-adsense'] && req.headers['x-disable-adsense'] === config.secret; res.locals.breadcrumb = req.path.split('/').splice(1, 3, null); res.locals.user = req.session.user; res.cookie('url', req.path.startsWith('/auth') ? '/' : req.path); diff --git a/test/Routes/API.js b/test/Routes/API.js index 831b1e4..da8c483 100644 --- a/test/Routes/API.js +++ b/test/Routes/API.js @@ -1,4 +1,4 @@ -const { describe, it, expect, request, ratelimitBypass, resetRatelimits, ratelimitTest, locale, titleCheck, db } = require('../base'); +const { describe, it, expect, request, ratelimitBypass, resetRatelimits, checks, locale, db } = require('../base'); const listProps = require('../../src/Util/listProps'); describe('Invalid route (/api/helloworld)', () => { @@ -71,7 +71,7 @@ describe('/api/docs', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `API Docs - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `API Docs - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -191,7 +191,7 @@ describe('/api/docs/libs', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `Libraries - API Docs - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `Libraries - API Docs - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -355,7 +355,7 @@ describe('/api/lists', () => { }); }); it('does not ratelimit requests spaced correctly', function (done) { - ratelimitTest(this, 1, test, done); + checks.ratelimit(this, 1, test, done); }); }); @@ -505,7 +505,7 @@ describe('/api/lists/:id', () => { }); }); it('does not ratelimit requests spaced correctly', function (done) { - ratelimitTest(this, 1, test, done, 404); + checks.ratelimit(this, 1, test, done, 404); }); }); @@ -535,6 +535,100 @@ describe('/api/lists/:id', () => { }); }); +describe('/api/legacy-ids', () => { + describe('GET', () => { + const test = () => ratelimitBypass(request().get('/api/legacy-ids')); + it('returns an OK status code', done => { + test().end((err, res) => { + expect(res).to.have.status(200); + done(); + }); + }); + it('has a permissive CORS header', done => { + test().end((err, res) => { + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + done(); + }); + }); + it('returns a valid JSON body', done => { + test().end((err, res) => { + expect(res).to.be.json; + done(); + }); + }); + it('contains an object of strings', done => { + test().end((err, res) => { + expect(res.body).to.be.a('object'); + const entries = Object.values(res.body); + entries.forEach(entry => { + expect(entry).to.be.a('string'); + }); + done(); + }); + }); + }); + + describe('GET (Ratelimited)', () => { + const test = () => request().get('/api/legacy-ids'); + it('ratelimits spam requests', done => { + resetRatelimits().end(() => { + test().end(() => { + }); + setTimeout(() => { + test().end((err, res) => { + expect(res).to.have.status(429); + expect(res).to.be.json; + + expect(res.body).to.have.property('error', true); + expect(res.body).to.have.property('status', 429); + + expect(res.body).to.have.property('retry_after'); + expect(res.body.retry_after).to.be.a('number'); + + expect(res.body).to.have.property('ratelimit_reset'); + expect(res.body.ratelimit_reset).to.be.a('number'); + + expect(res.body).to.have.property('ratelimit_ip'); + expect(res.body.ratelimit_ip).to.be.a('string'); + + expect(res.body).to.have.property('ratelimit_route', '/api/legacy-ids'); + expect(res.body).to.have.property('ratelimit_bot_id', ''); + done(); + }); + }, 200); + }); + }); + it('does not ratelimit requests spaced correctly', function (done) { + checks.ratelimit(this, 1, test, done); + }); + }); + + describe('POST', () => { + const test = () => ratelimitBypass(request().post('/api/legacy-ids')); + it('returns a Not Found status code', done => { + test().end((err, res) => { + expect(res).to.have.status(404); + done(); + }); + }); + it('has a permissive CORS header', done => { + test().end((err, res) => { + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + done(); + }); + }); + it('returns an error JSON body', done => { + test().end((err, res) => { + expect(res).to.be.json; + expect(res.body).to.have.property('error', true); + expect(res.body).to.have.property('status', 404); + expect(res.body).to.have.property('message', 'Endpoint not found'); + done(); + }); + }); + }); +}); + describe('/api/count', () => { describe('GET', () => { const test = () => ratelimitBypass(request().get('/api/count')); @@ -1069,7 +1163,7 @@ describe('/api/count', () => { bot_id: '123456789123456789', server_count: 10 }); - ratelimitTest(this, 120, test, done); + checks.ratelimit(this, 120, test, done); }); }); }); @@ -1281,7 +1375,7 @@ describe('/api/bots/:id', () => { }); }); it('does not ratelimit requests spaced correctly', function (done) { - ratelimitTest(this, 30, test, done, 400); + checks.ratelimit(this, 30, test, done, 400); }); }); @@ -1332,97 +1426,3 @@ describe('/api/bots/:id', () => { }); }); }); - -describe('/api/legacy-ids', () => { - describe('GET', () => { - const test = () => ratelimitBypass(request().get('/api/legacy-ids')); - it('returns an OK status code', done => { - test().end((err, res) => { - expect(res).to.have.status(200); - done(); - }); - }); - it('has a permissive CORS header', done => { - test().end((err, res) => { - expect(res).to.have.header('Access-Control-Allow-Origin', '*'); - done(); - }); - }); - it('returns a valid JSON body', done => { - test().end((err, res) => { - expect(res).to.be.json; - done(); - }); - }); - it('contains an object of strings', done => { - test().end((err, res) => { - expect(res.body).to.be.a('object'); - const entries = Object.values(res.body); - entries.forEach(entry => { - expect(entry).to.be.a('string'); - }); - done(); - }); - }); - }); - - describe('GET (Ratelimited)', () => { - const test = () => request().get('/api/legacy-ids'); - it('ratelimits spam requests', done => { - resetRatelimits().end(() => { - test().end(() => { - }); - setTimeout(() => { - test().end((err, res) => { - expect(res).to.have.status(429); - expect(res).to.be.json; - - expect(res.body).to.have.property('error', true); - expect(res.body).to.have.property('status', 429); - - expect(res.body).to.have.property('retry_after'); - expect(res.body.retry_after).to.be.a('number'); - - expect(res.body).to.have.property('ratelimit_reset'); - expect(res.body.ratelimit_reset).to.be.a('number'); - - expect(res.body).to.have.property('ratelimit_ip'); - expect(res.body.ratelimit_ip).to.be.a('string'); - - expect(res.body).to.have.property('ratelimit_route', '/api/legacy-ids'); - expect(res.body).to.have.property('ratelimit_bot_id', ''); - done(); - }); - }, 200); - }); - }); - it('does not ratelimit requests spaced correctly', function (done) { - ratelimitTest(this, 1, test, done); - }); - }); - - describe('POST', () => { - const test = () => ratelimitBypass(request().post('/api/legacy-ids')); - it('returns a Not Found status code', done => { - test().end((err, res) => { - expect(res).to.have.status(404); - done(); - }); - }); - it('has a permissive CORS header', done => { - test().end((err, res) => { - expect(res).to.have.header('Access-Control-Allow-Origin', '*'); - done(); - }); - }); - it('returns an error JSON body', done => { - test().end((err, res) => { - expect(res).to.be.json; - expect(res.body).to.have.property('error', true); - expect(res.body).to.have.property('status', 404); - expect(res.body).to.have.property('message', 'Endpoint not found'); - done(); - }); - }); - }); -}); diff --git a/test/Routes/Authentication.js b/test/Routes/Authentication.js index 6ae36d2..d252abf 100644 --- a/test/Routes/Authentication.js +++ b/test/Routes/Authentication.js @@ -1,4 +1,4 @@ -const { describe, it, expect, request } = require('../base'); +const { describe, it, expect, request, auth } = require('../base'); describe('/auth', () => { describe('GET', () => { @@ -24,11 +24,45 @@ describe('/auth', () => { describe('/auth/logout', () => { describe('GET', () => { - const test = () => request().get('/auth/logout').redirects(0); - it('redirects to back to the homepage', done => { - test().end((err, res) => { - expect(res).to.redirectTo('/'); - done(); + describe('As an anonymous user', () => { + it('redirects to back to the homepage', done => { + auth.asAnon(request().get('/')).end((err1, res1) => { + expect(res1.text).to.include('Sign in with Discord'); + + auth.asPrevious(request().get('/')).end((err2, res2) => { + expect(res2.text).to.include('Sign in with Discord'); + + auth.asPrevious(request().get('/auth/logout')).redirects(0).end((err3, res3) => { + expect(res3).to.redirectTo('/'); + + auth.asPrevious(request().get('/')).end((err4, res4) => { + expect(res4.text).to.include('Sign in with Discord'); + done(); + }); + }); + }); + }); + }); + }); + + describe('As a logged in user', () => { + it('redirects to back to the homepage', done => { + auth.asUser(request().get('/')).end((err1, res1) => { + expect(res1.text).to.include(''); + + auth.asPrevious(request().get('/')).end((err2, res2) => { + expect(res2.text).to.include(''); + + auth.asPrevious(request().get('/auth/logout')).redirects(0).end((err3, res3) => { + expect(res3).to.redirectTo('/'); + + auth.asPrevious(request().get('/')).end((err4, res4) => { + expect(res4.text).to.include('Sign in with Discord'); + done(); + }); + }); + }); + }); }); }); }); diff --git a/test/Routes/Index.js b/test/Routes/Index.js index c408a98..0528f68 100644 --- a/test/Routes/Index.js +++ b/test/Routes/Index.js @@ -1,4 +1,4 @@ -const { describe, it, expect, request, db, locale, titleCheck } = require('../base'); +const { describe, it, expect, request, db, locale, checks, auth } = require('../base'); const renderer = new (require('../../src/Structure/Markdown'))(); describe('/', () => { @@ -13,7 +13,7 @@ describe('/', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -46,7 +46,7 @@ describe('/helloworld', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `Page not found - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `Page not found - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -73,7 +73,7 @@ describe('/about', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `About - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `About - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -99,6 +99,317 @@ describe('/about', () => { }); }); +describe('/about/manage', () => { + describe('GET', () => { + describe('As an anonymous user', () => { + const test = () => request().get('/about/manage'); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + + describe('As a logged in user', () => { + const test = () => auth.asUser(request().get('/about/manage')); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + + describe('As a moderator', () => { + const test = () => auth.asMod(request().get('/about/manage')); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + + describe('As an administrator', () => { + const test = () => auth.asAdmin(request().get('/about/manage')); + it('returns an OK status code', done => { + test().end((err, res) => { + expect(res).to.have.status(200); + done(); + }); + }); + it('has the correct page title', done => { + test().end((err, res) => { + expect(res).to.be.html; + checks.title(res, `About - ${locale('site_name')} - ${locale('short_desc')}`); + done(); + }); + }); + it('renders the expected information', done => { + db.select('id', 'title').from('about').then(sections => { + test().end((err, res) => { + expect(res).to.be.html; + + // Confirm header + expect(res.text).to.include(`About Manager`); + + // Confirm sections + sections.forEach(section => { + expect(res.text).to.include(section.name); + expect(res.text).to.include(`href="/about/manage/${section.id}"`); + }); + + done(); + }); + }); + }); + }); + }); +}); + +describe('/about/manage/add', () => { + describe('As an anonymous user', () => { + describe('GET', () => { + const test = () => request().get('/about/manage/add'); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + + describe('POST', () => { + const test = () => request().post('/about/manage/add'); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); + + describe('As a logged in user', () => { + describe('GET', () => { + const test = () => auth.asUser(request().get('/about/manage/add')); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + + describe('POST', () => { + const test = () => auth.asUser(request().post('/about/manage/add')); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); + + describe('As a moderator', () => { + describe('GET', () => { + const test = () => auth.asMod(request().get('/about/manage/add')); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + + describe('POST', () => { + const test = () => auth.asMod(request().post('/about/manage/add')); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); + + describe('As an administrator', () => { + describe('GET', () => { + const test = () => auth.asAdmin(request().get('/about/manage/add')); + it('returns an OK status code', done => { + test().end((err, res) => { + expect(res).to.have.status(200); + done(); + }); + }); + it('has the correct page title', done => { + test().end((err, res) => { + expect(res).to.be.html; + checks.title(res, `Add Section - ${locale('site_name')} - ${locale('short_desc')}`); + done(); + }); + }); + }); + }); +}); + +describe('/about/manage/:id', () => { + const sectionId = 'intro'; + describe('As an anonymous user', () => { + describe(`GET (:id = ${sectionId})`, () => { + const test = () => request().get(`/about/manage/${sectionId}`); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + + describe(`POST (:id = ${sectionId})`, () => { + const test = () => request().post(`/about/manage/${sectionId}`); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); + + describe('As a logged in user', () => { + describe(`GET (:id = ${sectionId})`, () => { + const test = () => auth.asUser(request().get(`/about/manage/${sectionId}`)); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + + describe(`POST (:id = ${sectionId})`, () => { + const test = () => auth.asUser(request().post(`/about/manage/${sectionId}`)); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); + + describe('As a moderator', () => { + describe(`GET (:id = ${sectionId})`, () => { + const test = () => auth.asMod(request().get(`/about/manage/${sectionId}`)); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + + describe(`POST (:id = ${sectionId})`, () => { + const test = () => auth.asMod(request().post(`/about/manage/${sectionId}`)); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); + + describe('As an administrator', () => { + describe(`GET (:id = ${sectionId})`, () => { + const test = () => auth.asAdmin(request().get(`/about/manage/${sectionId}`)); + it('returns an OK status code', done => { + test().end((err, res) => { + expect(res).to.have.status(200); + done(); + }); + }); + it('has the correct page title', done => { + test().end((err, res) => { + expect(res).to.be.html; + checks.title(res, `Edit Section - ${locale('site_name')} - ${locale('short_desc')}`); + done(); + }); + }); + }); + }); +}); + +describe('/about/manage/:id/delete', () => { + const sectionId = 'intro'; + describe('As an anonymous user', () => { + describe(`GET (:id = ${sectionId})`, () => { + const test = () => request().get(`/about/manage/${sectionId}/delete`); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); + + describe('As a logged in user', () => { + describe(`GET (:id = ${sectionId})`, () => { + const test = () => auth.asUser(request().get(`/about/manage/${sectionId}/delete`)); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); + + describe('As a moderator', () => { + describe(`GET (:id = ${sectionId})`, () => { + const test = () => auth.asMod(request().get(`/about/manage/${sectionId}/delete`)); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); +}); + +describe('/sitemap', () => { + describe('GET', () => { + const test = () => request().get('/sitemap'); + it('returns an OK status code', done => { + test().end((err, res) => { + expect(res).to.have.status(200); + done(); + }); + }); + it('has the correct page title', done => { + test().end((err, res) => { + expect(res).to.be.html; + checks.title(res, `Sitemap - ${locale('site_name')} - ${locale('short_desc')}`); + done(); + }); + }); + it('renders the expected content', done => { + test().end((err, res) => { + expect(res).to.be.html; + + // Confirm button + expect(res.text).to.include('href="/sitemap.xml">View as XML'); + done(); + }); + }); + }); +}); + describe('Discord Invite', () => { const routes = [ '/invite', diff --git a/test/Routes/Lists.js b/test/Routes/Lists.js index ff14032..8a32450 100644 --- a/test/Routes/Lists.js +++ b/test/Routes/Lists.js @@ -1,41 +1,30 @@ -const { describe, it, expect, request, db, locale, titleCheck, authCheck } = require('../base'); +const { describe, it, expect, request, db, locale, checks, auth, fetchPage } = require('../base'); describe('/lists', () => { describe('GET', () => { const test = () => request().get('/lists'); - it('returns an OK status code', done => { - test().end((err, res) => { - expect(res).to.have.status(200); + fetchPage(test); + + it('returns an OK status code', function(done) { + expect(this.res).to.have.status(200); + done(); + }); + + checks.meta(`All Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); + + describe('renders the expected content', () => { + it('has the correct title', function(done) { + expect(this.res.text).to.include('All Bot Lists'); done(); }); - }); - it('has the correct page title', done => { - test().end((err, res) => { - expect(res).to.be.html; - titleCheck(res, `All Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); + it('has the stats footer', function(done) { + const footer = this.page.querySelector(".hero.card .hero-body.hero-stats.card-body"); + expect(footer).to.exist; + expect(footer.innerHTML).to.include(`${locale('site_name')} - Bot List Stats`); done(); }); - }); - it('renders the expected content', done => { - db.select('name', 'url').from('lists').where({ display: true, defunct: false }).then(lists => { - test().end((err, res) => { - expect(res).to.be.html; - - // Confirm header - expect(res.text).to.include('All Bot Lists'); - - // Confirm footer stats - expect(res.text).to.include(`${locale('site_name')} - Bot List Stats`); - - // Confirm list cards - lists.forEach(list => { - expect(res.text).to.include(list.name); - expect(res.text).to.include(list.url); - }); - done(); - }); - }); + checks.listCards(test, db, { display: true, defunct: false }); }); }); }); @@ -52,7 +41,7 @@ describe('/lists/best-practices', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `Best Practices for Discord Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `Best Practices for Discord Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -79,7 +68,7 @@ describe('/lists/new', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `New Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `New Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -123,7 +112,7 @@ describe('/lists/defunct', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `Defunct Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `Defunct Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -163,7 +152,7 @@ describe('/lists/hidden', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `Hidden Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `Hidden Bot Lists - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -203,7 +192,7 @@ describe('/lists/features', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `All List Features - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `All List Features - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -240,7 +229,7 @@ describe('/lists/features/:id', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `Has Voting - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `Has Voting - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -290,7 +279,7 @@ describe('/lists/search', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `Bot List Search - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `Bot List Search - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -328,7 +317,7 @@ describe('/lists/search/:query', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `Bot List Search - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `Bot List Search - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -387,7 +376,7 @@ describe('/lists/:id', () => { it('has the correct page title', done => { test().end((err, res) => { expect(res).to.be.html; - titleCheck(res, `${data.name} (${data.id}) - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `${data.name} (${data.id}) - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -472,7 +461,7 @@ describe('/lists/:id', () => { test().end((err, res) => { expect(res).to.have.status(200); expect(res).to.be.html; - titleCheck(res, `${data.name} (${data.id}) - ${locale('site_name')} - ${locale('short_desc')}`); + checks.title(res, `${data.name} (${data.id}) - ${locale('site_name')} - ${locale('short_desc')}`); done(); }); }); @@ -499,34 +488,59 @@ describe('/lists/:id', () => { describe('/lists/:id/edit', () => { const listId = 'botlist.space'; - describe(`GET (:id = ${listId})`, () => { - const test = () => request().get(`/lists/${listId}/edit`); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); + describe('As an anonymous user', () => { + describe(`GET (:id = ${listId})`, () => { + const test = () => request().get(`/lists/${listId}/edit`); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); }); }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); - done(); + + describe(`POST (:id = ${listId})`, () => { + const test = () => request().post(`/lists/${listId}/edit`); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); }); }); }); - describe(`POST (:id = ${listId})`, () => { - const test = () => request().post(`/lists/${listId}/edit`); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); + describe('As a logged in user', () => { + describe(`GET (:id = ${listId})`, () => { + const test = () => auth.asUser(request().get(`/lists/${listId}/edit`)); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); }); }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); - done(); + + describe(`POST (:id = ${listId})`, () => { + const test = () => auth.asUser(request().post(`/lists/${listId}/edit`)); + it('returns the authentication required message', done => { + test().end((err, res) => { + checks.authRequired(res); + done(); + }); + }); + }); + }); + + describe('As a moderator', () => { + describe(`GET (:id = ${listId})`, () => { + const test = () => auth.asMod(request().get(`/lists/${listId}/edit`)); + it('renders the edit page', done => { + test().end((err, res) => { + expect(res).to.have.status(200); + expect(res).to.be.html; + done(); + }); }); }); }); @@ -536,15 +550,9 @@ describe('/lists/:id/icon', () => { const listId = 'botlist.space'; describe(`GET (:id = ${listId})`, () => { const test = () => request().get(`/lists/${listId}/icon`); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -554,15 +562,9 @@ describe('/lists/:id/icon', () => { describe('/lists/add', () => { describe('GET', () => { const test = () => request().get('/lists/add'); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -570,15 +572,9 @@ describe('/lists/add', () => { describe('POST', () => { const test = () => request().post('/lists/add'); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -588,15 +584,9 @@ describe('/lists/add', () => { describe('/lists/legacy-ids', () => { describe('GET', () => { const test = () => request().get('/lists/legacy-ids'); - it('returns a Forbidden status code', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -604,15 +594,9 @@ describe('/lists/legacy-ids', () => { describe('POST', () => { const test = () => request().post('/lists/legacy-ids'); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -622,15 +606,9 @@ describe('/lists/legacy-ids', () => { describe('/lists/features/manage', () => { describe('GET', () => { const test = () => request().get('/lists/features/manage'); - it('returns a Forbidden status code', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -640,15 +618,9 @@ describe('/lists/features/manage', () => { describe('/lists/features/manage/add', () => { describe('GET', () => { const test = () => request().get('/lists/features/manage/add'); - it('returns a Forbidden status code', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -656,15 +628,9 @@ describe('/lists/features/manage/add', () => { describe('POST', () => { const test = () => request().post('/lists/features/manage/add'); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -674,15 +640,9 @@ describe('/lists/features/manage/add', () => { describe('/lists/features/manage/:id', () => { describe('GET', () => { const test = () => request().get('/lists/features/manage/1'); - it('returns a Forbidden status code', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -690,15 +650,9 @@ describe('/lists/features/manage/:id', () => { describe('POST', () => { const test = () => request().post('/lists/features/manage/1'); - it('returns a Forbidden status code', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -708,15 +662,9 @@ describe('/lists/features/manage/:id', () => { describe('/lists/features/manage/:id/delete', () => { describe('GET', () => { const test = () => request().get('/lists/features/manage/1/delete'); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); diff --git a/test/Routes/Tasks.js b/test/Routes/Tasks.js index 15457f6..d724c4c 100644 --- a/test/Routes/Tasks.js +++ b/test/Routes/Tasks.js @@ -1,17 +1,11 @@ -const { describe, it, expect, request, authCheck } = require('../base'); +const { describe, it, request, checks } = require('../base'); describe('/tasks', () => { describe('GET', () => { const test = () => request().get('/tasks'); - it('returns a Forbidden status code', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -21,15 +15,9 @@ describe('/tasks', () => { describe('/tasks/run/:id', () => { describe('POST', () => { const test = () => request().post('/tasks/run/0'); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); diff --git a/test/Routes/Test.js b/test/Routes/Test.js index b9fe65b..aee4b94 100644 --- a/test/Routes/Test.js +++ b/test/Routes/Test.js @@ -1,17 +1,11 @@ -const { describe, it, expect, request, authCheck } = require('../base'); +const { describe, it, request, checks } = require('../base'); describe('/test', () => { describe('GET', () => { const test = () => request().get('/test'); - it('returns a Forbidden status code', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -21,15 +15,9 @@ describe('/test', () => { describe('/test/start', () => { describe('GET', () => { const test = () => request().get('/test/start'); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -39,15 +27,9 @@ describe('/test/start', () => { describe('/test/restart', () => { describe('GET', () => { const test = () => request().get('/test/restart'); - it('returns a Forbidden status code', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { - test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); @@ -57,15 +39,9 @@ describe('/test/restart', () => { describe('/test/progress', () => { describe('GET', () => { const test = () => request().get('/test/progress'); - it('returns a Forbidden status code', done => { - test().end((err, res) => { - expect(res).to.have.status(403); - done(); - }); - }); - it('renders the authentication required message', done => { + it('returns the authentication required message', done => { test().end((err, res) => { - authCheck(res); + checks.authRequired(res); done(); }); }); diff --git a/test/base.js b/test/base.js index 4f4517f..b8cd13d 100644 --- a/test/base.js +++ b/test/base.js @@ -1,62 +1,18 @@ const config = require('../config'); -const i18n = require('../src/Util/i18n'); +const locale = require('../src/Util/i18n').__; const db = require('../db/db')(); - -const { describe, it } = require('mocha'); - +const checks = require('./helpers/checks'); +const auth = require('./helpers/auth'); +const request = require('./helpers/request'); +const { ratelimitBypass, resetRatelimits } = require('./helpers/ratelimits'); +const dom = require('./helpers/dom'); +const fetchPage = require('./helpers/fetchPage'); +const { before, describe, it } = require('mocha'); const chai = require('chai'); +const { expect } = require('chai'); const chaiHttp = require('chai-http'); -const expect = chai.expect; chai.use(chaiHttp); -const target = `${config.baseURL}:${config.port}`; -const request = () => chai.request(target); -const ratelimitBypass = (req) => req.set('X-Ratelimit-Bypass', config.secret); -const resetRatelimits = () => ratelimitBypass(request().get('/api/reset')); - -const ratelimitTest = (context, limit, test, done, status = 200) => { - context.retries(0); - context.slow((limit * 1.15 + 1.5) * 1000); - context.timeout((limit * 1.25 + 3) * 1000); - resetRatelimits().end(() => { - test().end(() => { - }); - setTimeout(() => { - test().end((err, res) => { - expect(res).to.have.status(status); - expect(res).to.be.json; - done(); - }); - }, (limit * 1.05 + 0.5) * 1000); - }); -}; - -const locale = i18n.__; - -const authCheck = res => { - expect(res).to.be.html; - expect(res.text).to.include('This page requires authentication to access'); - expect(res.text).to.include(`Sign in to ${locale('site_name')}`); - expect(res.text).to.include('A 403 error has occurred... :('); -}; - -const titleCheck = (res, expectedTitle) => { - expect(res).to.be.html; - - // Generic - expect(res.text).to.include(`${expectedTitle}`); - expect(res.text).to.include(``); - - // OG - expect(res.text).to.include(``); - expect(res.text).to.include(``); - expect(res.text).to.include(``); - - // Twitter - expect(res.text).to.include(``); - expect(res.text).to.include(``); -}; - const compareObjectProps = (a, b) => { const missing = []; const aProps = Object.keys(a); @@ -80,18 +36,19 @@ const compareObjects = (template, actual) => { }; module.exports = { + before, describe, it, expect, - target, secret: config.secret, request, ratelimitBypass, resetRatelimits, - ratelimitTest, db, locale, - authCheck, - titleCheck, + checks, + auth, + dom, + fetchPage, compareObjects }; diff --git a/test/helpers/auth.js b/test/helpers/auth.js new file mode 100644 index 0000000..123350b --- /dev/null +++ b/test/helpers/auth.js @@ -0,0 +1,11 @@ +const config = require('../../config'); + +const clear = req => req.unset('X-Auth-As-Anon').unset('X-Auth-As-User').unset('X-Auth-As-Mod').unset('X-Auth-As-Admin'); + +const asAnon = req => clear(req).set('X-Auth-As-Anon', config.secret); +const asUser = req => clear(req).set('X-Auth-As-User', config.secret); +const asMod = req => clear(req).set('X-Auth-As-Mod', config.secret); +const asAdmin = req => clear(req).set('X-Auth-As-Admin', config.secret); +const asPrevious = req => clear(req); + +module.exports = { asAnon, asUser, asMod, asAdmin, asPrevious }; diff --git a/test/helpers/checks.js b/test/helpers/checks.js new file mode 100644 index 0000000..b7e1b2f --- /dev/null +++ b/test/helpers/checks.js @@ -0,0 +1,176 @@ +const { describe, it } = require('mocha'); +const { expect } = require('chai'); +const locale = require('../../src/Util/i18n').__; +const { resetRatelimits } = require('./ratelimits'); +const fetchPage = require('./fetchPage'); +const auth = require('./auth'); + +const ratelimit = (context, limit, test, done, status = 200) => { + context.retries(0); + context.slow((limit * 1.15 + 1.5) * 1000); + context.timeout((limit * 1.25 + 3) * 1000); + resetRatelimits().end(() => { + test().end(() => { + }); + setTimeout(() => { + test().end((err, res) => { + expect(res).to.have.status(status); + expect(res).to.be.json; + done(); + }); + }, (limit * 1.05 + 0.5) * 1000); + }); +}; + +const authRequired = res => { + expect(res).to.have.status(403); + expect(res).to.be.html; + expect(res.text).to.include('This page requires authentication to access'); + expect(res.text).to.include(`Sign in to ${locale('site_name')}`); + expect(res.text).to.include('A 403 error has occurred... :('); +}; + +const title = (res, expectedTitle) => { + expect(res).to.be.html; + + // Generic + expect(res.text).to.include(`${expectedTitle}`); + expect(res.text).to.include(``); + + // OG + expect(res.text).to.include(``); + expect(res.text).to.include(``); + expect(res.text).to.include(``); + + // Twitter + expect(res.text).to.include(``); + expect(res.text).to.include(``); +}; + +const meta = expectedTitle => { + describe('has the correct page metadata', () => { + it('is a valid HTML page', function (done) { + expect(this.res).to.be.html; + done(); + }); + it('has the correct title', function (done) { + const title = this.page.querySelector('title'); + expect(title).to.exist; + expect(title.textContent).to.eq(expectedTitle); + done(); + }); + it('has the correct description', function (done) { + const description = this.page.querySelector('meta[name="description"]'); + expect(description).to.exist; + expect(description.hasAttribute('content')).to.be.true; + expect(description.getAttribute('content')).to.eq(`${locale('site_name')} - ${locale('full_desc')}`); + done(); + }); + describe('OpenGraph', () => { + it('has the correct title', function (done) { + const title = this.page.querySelector('meta[property="og:title"]'); + expect(title).to.exist; + expect(title.hasAttribute('content')).to.be.true; + expect(title.getAttribute('content')).to.eq(expectedTitle); + done(); + }); + it('has the correct site name', function (done) { + const title = this.page.querySelector('meta[property="og:site_name"]'); + expect(title).to.exist; + expect(title.hasAttribute('content')).to.be.true; + expect(title.getAttribute('content')).to.eq(locale('site_name')); + done(); + }); + it('has the correct description', function (done) { + const description = this.page.querySelector('meta[property="og:description"]'); + expect(description).to.exist; + expect(description.hasAttribute('content')).to.be.true; + expect(description.getAttribute('content')).to.eq(locale('full_desc')); + done(); + }); + }); + describe('Twitter', () => { + it('has the correct title', function (done) { + const title = this.page.querySelector('meta[name="twitter:title"]'); + expect(title).to.exist; + expect(title.hasAttribute('content')).to.be.true; + expect(title.getAttribute('content')).to.eq(expectedTitle); + done(); + }); + it('has the correct description', function (done) { + const description = this.page.querySelector('meta[name="twitter:description"]'); + expect(description).to.exist; + expect(description.hasAttribute('content')).to.be.true; + expect(description.getAttribute('content')).to.eq(locale('full_desc')); + done(); + }); + }); + }); +}; + +const listCards = (test, db, where) => { + describe('contains the list cards', () => { + fetchPage(test); + + before('fetch list cards', function(done) { + db.select('id', 'name', 'url').from('lists').where(where).then(lists => { + this.listCards = lists; + done(); + }); + }); + + it('has the list names', function(done) { + this.listCards.forEach(list => { + expect(this.res.text).to.include(list.name); + }); + done(); + }); + it('has the list urls', function(done) { + this.listCards.forEach(list => { + expect(this.res.text).to.include(list.url); + }); + done(); + }); + + describe('card buttons', () => { + it('has the list information button', function(done) { + this.listCards.forEach(list => { + expect(this.page.querySelector(`.card a.button[href="/lists/${list.id}"]`)).to.exist; + }); + done(); + }); + + describe('as an anonymous user', () => { + fetchPage(() => auth.asAnon(test())); + it('does not have the edit button', function(done) { + this.listCards.forEach(list => { + expect(this.page.querySelector(`.card a.button[href="/lists/${list.id}/edit"]`)).to.not.exist; + }); + done(); + }); + }); + + describe('as a logged in user', () => { + fetchPage(() => auth.asUser(test())); + it('does not have the edit button', function(done) { + this.listCards.forEach(list => { + expect(this.page.querySelector(`.card a.button[href="/lists/${list.id}/edit"]`)).to.not.exist; + }); + done(); + }); + }); + + describe('as a moderator', () => { + fetchPage(() => auth.asMod(test())); + it('has the edit button', function(done) { + this.listCards.forEach(list => { + expect(this.page.querySelector(`.card a.button[href="/lists/${list.id}/edit"]`)).to.exist; + }); + done(); + }); + }); + }); + }); +}; + +module.exports = { ratelimit, authRequired, title, meta, listCards }; diff --git a/test/helpers/dom.js b/test/helpers/dom.js new file mode 100644 index 0000000..e9ee13c --- /dev/null +++ b/test/helpers/dom.js @@ -0,0 +1,6 @@ +const jsdom = require("jsdom"); +const { JSDOM } = jsdom; + +module.exports = res => { + return (new JSDOM(res.text)).window.document; +}; diff --git a/test/helpers/fetchpage.js b/test/helpers/fetchpage.js new file mode 100644 index 0000000..788f511 --- /dev/null +++ b/test/helpers/fetchpage.js @@ -0,0 +1,12 @@ +const { before } = require('mocha'); +const dom = require('./dom'); + +module.exports = test => { + before('fetch the page', function(done) { + test().end((_, r) => { + this.res = r; + this.page = dom(r); + done(); + }); + }); +}; diff --git a/test/helpers/ratelimits.js b/test/helpers/ratelimits.js new file mode 100644 index 0000000..14a2ed0 --- /dev/null +++ b/test/helpers/ratelimits.js @@ -0,0 +1,7 @@ +const config = require('../../config'); +const request = require('./request'); + +const ratelimitBypass = (req) => req.set('X-Ratelimit-Bypass', config.secret); +const resetRatelimits = () => ratelimitBypass(request().get('/api/reset')); + +module.exports = { ratelimitBypass, resetRatelimits }; diff --git a/test/helpers/request.js b/test/helpers/request.js new file mode 100644 index 0000000..0196952 --- /dev/null +++ b/test/helpers/request.js @@ -0,0 +1,20 @@ +const config = require('../../config'); +const { asAnon } = require('./auth'); +const chai = require('chai'); +const chaiHttp = require('chai-http'); +chai.use(chaiHttp); + +const target = `${config.baseURL}:${config.port}`; +let agent; +const base = () => { + if (!agent) agent = chai.request.agent(target); + return agent; +}; + +module.exports = () => new Proxy(base, { + get(target, method) { + return function (...args) { + return asAnon(target()[method].apply(agent, args)); + }; + } +}); diff --git a/test/mocha.opts b/test/mocha.opts index 003d848..3080ec3 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,6 +1,7 @@ # mocha.opts --recursive --exclude base.js + --exclude helpers --slow 600 --timeout 12000 --retries 1