diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 254ff75b28..afb7b17f00 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -142,5 +142,11 @@ "cardTypeRequired": "Card type required", "cardTypeNotAllowed": "Unkown card type.", "mysteryItemIsEmpty": "Mystery items are empty", - "mysteryItemOpened": "Mystery item opened." + "mysteryItemOpened": "Mystery item opened.", + "invalidCoupon": "Invalid coupon code.", + "couponUsed": "Coupon code already used.", + "noSudoAccess": "You don't have sudo access.", + "couponCodeRequired": "The coupon code is required.", + "eventRequired": "\"req.params.event\" is required.", + "countRequired": "\"req.query.count\" is required." } diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 829f47ba96..e71cde8258 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -3,12 +3,7 @@ import eslint from 'gulp-eslint'; const SERVER_FILES = [ './website/src/**/api-v3/**/*.js', - './website/src/models/user.js', - './website/src/models/task.js', - './website/src/models/group.js', - './website/src/models/challenge.js', - './website/src/models/tag.js', - './website/src/models/emailUnsubscription.js', + './website/src/models/**', './website/src/server.js', ]; const COMMON_FILES = [ diff --git a/test/api/v3/integration/coupons/GET-coupons.test.js b/test/api/v3/integration/coupons/GET-coupons.test.js new file mode 100644 index 0000000000..775ddfc3fe --- /dev/null +++ b/test/api/v3/integration/coupons/GET-coupons.test.js @@ -0,0 +1,39 @@ +import { + generateUser, + translate as t, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET /coupons/', () => { + let user; + before(async () => { + await resetHabiticaDB(); + }); + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error if user has no sudo permission', async () => { + await user.get('/user'); // needed so the request after this will authenticate with the correct cookie session + await expect(user.get(`/coupons`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('noSudoAccess'), + }); + }); + + it('should return the coupons in CSV format ordered by creation date', async () => { + await user.update({ + 'contributor.sudo': true, + }); + + let coupons = await user.post('/coupons/generate/wondercon?count=11'); + let res = await user.get(`/coupons`); + let splitRes = res.split('\n'); + + expect(splitRes.length).to.equal(13); + expect(splitRes[0]).to.equal('code,event,date,user'); + expect(splitRes[6].split(',')[1]).to.equal(coupons[5].event); + }); +}); diff --git a/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js b/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js new file mode 100644 index 0000000000..687ada750b --- /dev/null +++ b/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js @@ -0,0 +1,62 @@ +import { + generateUser, + translate as t, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /coupons/enter/:code', () => { + let user; + let sudoUser; + + before(async () => { + await resetHabiticaDB(); + }); + + beforeEach(async () => { + user = await generateUser(); + sudoUser = await generateUser({ + 'contributor.sudo': true, + }); + }); + + it('returns an error if code is missing', async () => { + await expect(user.post(`/coupons/enter`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); + + it('returns an error if code is invalid', async () => { + await expect(user.post(`/coupons/enter/notValid`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidCoupon'), + }); + }); + + it('returns an error if coupon has been used', async () => { + let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); + await user.post(`/coupons/enter/${coupon._id}`); // use coupon + + await expect(user.post(`/coupons/enter/${coupon._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('couponUsed'), + }); + }); + + it('should apply the coupon to the user', async () => { + let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); + let userRes = await user.post(`/coupons/enter/${coupon._id}`); + expect(userRes._id).to.equal(user._id); + expect(userRes.items.gear.owned.eyewear_special_wondercon_red).to.be.true; + expect(userRes.items.gear.owned.eyewear_special_wondercon_black).to.be.true; + expect(userRes.items.gear.owned.back_special_wondercon_black).to.be.true; + expect(userRes.items.gear.owned.back_special_wondercon_red).to.be.true; + expect(userRes.items.gear.owned.body_special_wondercon_red).to.be.true; + expect(userRes.items.gear.owned.body_special_wondercon_black).to.be.true; + expect(userRes.items.gear.owned.body_special_wondercon_gold).to.be.true; + expect(userRes.extra).to.eql({signupEvent: 'wondercon'}); + }); +}); diff --git a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js new file mode 100644 index 0000000000..005a78923f --- /dev/null +++ b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js @@ -0,0 +1,66 @@ +import { + generateUser, + translate as t, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; +import couponCode from 'coupon-code'; + +describe('POST /coupons/generate/:event', () => { + let user; + before(async () => { + await resetHabiticaDB(); + }); + + beforeEach(async () => { + user = await generateUser({ + 'contributor.sudo': true, + }); + }); + + it('returns an error if user has no sudo permission', async () => { + await user.update({ + 'contributor.sudo': false, + }); + + await expect(user.post(`/coupons/generate/aaa`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('noSudoAccess'), + }); + }); + + it('returns an error if event is missing', async () => { + await expect(user.post(`/coupons/generate`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); + + it('returns an error if event is invalid', async () => { + await expect(user.post(`/coupons/generate/notValid?count=1`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Coupon validation failed', + }); + }); + + it('returns an error if count is missing', async () => { + await expect(user.post(`/coupons/generate/notValid`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('should generate coupons', async () => { + await user.update({ + 'contributor.sudo': true, + }); + + let coupons = await user.post('/coupons/generate/wondercon?count=2'); + expect(coupons.length).to.equal(2); + expect(coupons[0].event).to.equal('wondercon'); + expect(couponCode.validate(coupons[1]._id)).to.not.equal(''); // '' means invalid + }); +}); diff --git a/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js b/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js new file mode 100644 index 0000000000..28de4ca460 --- /dev/null +++ b/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js @@ -0,0 +1,36 @@ +import { + generateUser, + requester, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /coupons/validate/:code', () => { + let api = requester(); + + before(async () => { + await resetHabiticaDB(); + }); + + it('returns an error if code is missing', async () => { + await expect(api.post(`/coupons/validate`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); + + it('returns true if coupon code is valid', async () => { + let sudoUser = await generateUser({ + 'contributor.sudo': true, + }); + + let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); + let res = await api.post(`/coupons/validate/${coupon._id}`); + expect(res).to.eql({valid: true}); + }); + + it('returns false if coupon code is valid', async () => { + let res = await api.post(`/coupons/validate/notValid`); + expect(res).to.eql({valid: false}); + }); +}); diff --git a/test/api/v3/unit/middlewares/ensureAccessRight.test.js b/test/api/v3/unit/middlewares/ensureAccessRight.test.js new file mode 100644 index 0000000000..6c50e3d674 --- /dev/null +++ b/test/api/v3/unit/middlewares/ensureAccessRight.test.js @@ -0,0 +1,57 @@ +/* eslint-disable global-require */ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import i18n from '../../../../../common/script/i18n'; +import { ensureAdmin, ensureSudo } from '../../../../../website/src/middlewares/api-v3/ensureAccessRight'; +import { NotAuthorized } from '../../../../../website/src/libs/api-v3/errors'; + +describe('ensure access middlewares', () => { + let res, req, next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + }); + + context('ensure admin', () => { + it('returns not authorized when user is not an admin', () => { + res.locals = {user: {contributor: {admin: false}}}; + + ensureAdmin(req, res, next); + + expect(next).to.be.calledWith(new NotAuthorized(i18n.t('noAdminAccess'))); + }); + + it('passes when user is an admin', () => { + res.locals = {user: {contributor: {admin: true}}}; + + ensureAdmin(req, res, next); + + expect(next).to.be.calledOnce; + expect(next.args[0]).to.be.empty; + }); + }); + + context('ensure sudo', () => { + it('returns not authorized when user is not a sudo user', () => { + res.locals = {user: {contributor: {sudo: false}}}; + + ensureSudo(req, res, next); + + expect(next).to.be.calledWith(new NotAuthorized(i18n.t('noSudoAccess'))); + }); + + it('passes when user is a sudo user', () => { + res.locals = {user: {contributor: {sudo: true}}}; + + ensureSudo(req, res, next); + + expect(next).to.be.calledOnce; + expect(next.args[0]).to.be.empty; + }); + }); +}); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index eb7769256a..489e0dd22e 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -5,6 +5,7 @@ import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; import mongo from './mongo'; // eslint-disable-line import moment from 'moment'; +import i18n from '../../common/script/i18n'; afterEach((done) => { sandbox.restore(); @@ -32,6 +33,9 @@ export function generateRes (options = {}) { group: generateGroup(options.localsGroup), }, set: sandbox.stub(), + t (string) { + return i18n.t(string); + }, }; return defaults(options, defaultRes); diff --git a/website/src/controllers/api-v3/coupon.js b/website/src/controllers/api-v3/coupon.js new file mode 100644 index 0000000000..0890878a54 --- /dev/null +++ b/website/src/controllers/api-v3/coupon.js @@ -0,0 +1,125 @@ +import csvStringify from '../../libs/api-v3/csvStringify'; +import { + authWithHeaders, + authWithSession, +} from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { ensureSudo } from '../../middlewares/api-v3/ensureAccessRight'; +import { model as Coupon } from '../../models/coupon'; +import _ from 'lodash'; +import couponCode from 'coupon-code'; + +let api = {}; + +/** + * @api {get} /coupons Get coupons (sudo users only) + * @apiVersion 3.0.0 + * @apiName GetCoupons + * @apiGroup Coupon + * + * @apiSuccess string Coupons in CSV format + */ +api.getCoupons = { + method: 'GET', + url: '/coupons', + middlewares: [authWithSession, cron, ensureSudo], + async handler (req, res) { + let coupons = await Coupon.find().sort('createdAt').lean().exec(); + + let output = [['code', 'event', 'date', 'user']].concat(_.map(coupons, coupon => { + return [coupon._id, coupon.event, coupon.createdAt, coupon.user]; + })); + let csv = await csvStringify(output); + + res.set({ + 'Content-Type': 'text/csv', + 'Content-disposition': `attachment; filename=habitica-coupons.csv`, + }); + res.status(200).send(csv); + }, +}; + +/** + * @api {post} /coupons/generate/:event Generate coupons for an event (sudo users only) + * @apiVersion 3.0.0 + * @apiName GenerateCoupons + * @apiGroup Coupon + * + * @apiParam {string} event The event for which the coupon should be generated + * @apiParam {number} count Query parameter to specify the number of coupon codes to generate + * + * @apiSuccess array Generated coupons + */ +api.generateCoupons = { + method: 'POST', + url: '/coupons/generate/:event', + middlewares: [authWithHeaders(), cron, ensureSudo], + async handler (req, res) { + req.checkParams('event', res.t('eventRequired')).notEmpty(); + req.checkQuery('count', res.t('countRequired')).notEmpty().isNumeric(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let coupons = await Coupon.generate(req.params.event, req.query.count); + res.respond(200, coupons); + }, +}; + +/** + * @api {post} /user/coupon/:code Enter coupon code + * @apiVersion 3.0.0 + * @apiName EnterCouponCode + * @apiGroup Coupon + * + * @apiParam {string} code The coupon code to apply + * + * @apiSuccess object User object + */ +api.enterCouponCode = { + method: 'POST', + url: '/coupons/enter/:code', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('code', res.t('couponCodeRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + await Coupon.apply(user, req, req.params.code); + res.respond(200, user); + }, +}; + +/** + * @api {post} /coupons/validate/:code Validate a coupon code + * @apiVersion 3.0.0 + * @apiName ValidateCoupon + * @apiGroup Coupon + * + * @apiSuccess valid {boolean} true or false + */ +api.validateCoupon = { + method: 'POST', + url: '/coupons/validate/:code', + middlewares: [authWithHeaders(true)], + async handler (req, res) { + req.checkParams('code', res.t('couponCodeRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let valid = false; + let code = couponCode.validate(req.params.code); + if (code) { + let coupon = await Coupon.findOne({_id: code}).exec(); + valid = coupon ? true : false; + } + + res.respond(200, {valid}); + }, +}; + +module.exports = api; diff --git a/website/src/controllers/api-v3/hall.js b/website/src/controllers/api-v3/hall.js index 917b3e487a..35d5dec2af 100644 --- a/website/src/controllers/api-v3/hall.js +++ b/website/src/controllers/api-v3/hall.js @@ -1,9 +1,9 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import { ensureAdmin } from '../../middlewares/api-v3/ensureAccessRight'; import cron from '../../middlewares/api-v3/cron'; import { model as User } from '../../models/user'; import { NotFound, - NotAuthorized, } from '../../libs/api-v3/errors'; import _ from 'lodash'; @@ -90,9 +90,8 @@ const heroAdminFields = 'contributor balance profile.name purchased items auth'; api.getHero = { method: 'GET', url: '/hall/heroes/:heroId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders(), cron, ensureAdmin], async handler (req, res) { - let user = res.locals.user; let heroId = req.params.heroId; req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID(); @@ -100,10 +99,6 @@ api.getHero = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - if (!user.contributor.admin) { - throw new NotAuthorized(res.t('noAdminAccess')); - } - let hero = await User .findById(heroId) .select(heroAdminFields) @@ -132,9 +127,8 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; api.updateHero = { method: 'PUT', url: '/hall/heroes/:heroId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders(), cron, ensureAdmin], async handler (req, res) { - let user = res.locals.user; let heroId = req.params.heroId; let updateData = req.body; @@ -143,10 +137,6 @@ api.updateHero = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - if (!user.contributor.admin) { - throw new NotAuthorized(res.t('noAdminAccess')); - } - let hero = await User.findById(heroId).exec(); if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); diff --git a/website/src/middlewares/api-v3/ensureAccessRight.js b/website/src/middlewares/api-v3/ensureAccessRight.js new file mode 100644 index 0000000000..2fb64ed8af --- /dev/null +++ b/website/src/middlewares/api-v3/ensureAccessRight.js @@ -0,0 +1,23 @@ +import { + NotAuthorized, +} from '../../libs/api-v3/errors'; + +export function ensureAdmin (req, res, next) { + let user = res.locals.user; + + if (!user.contributor.admin) { + return next(new NotAuthorized(res.t('noAdminAccess'))); + } + + next(); +} + +export function ensureSudo (req, res, next) { + let user = res.locals.user; + + if (!user.contributor.sudo) { + return next(new NotAuthorized(res.t('noSudoAccess'))); + } + + next(); +} diff --git a/website/src/models/coupon.js b/website/src/models/coupon.js index 3b0afb3f2a..4492e98690 100644 --- a/website/src/models/coupon.js +++ b/website/src/models/coupon.js @@ -1,59 +1,58 @@ -var mongoose = require("mongoose"); -var shared = require('../../../common'); -var _ = require('lodash'); -var async = require('async'); -var cc = require('coupon-code'); -var autoinc = require('mongoose-id-autoinc'); +/* eslint-disable camelcase */ -var CouponSchema = new mongoose.Schema({ - _id: {type: String, 'default': cc.generate}, - event: {type:String, enum:['wondercon','google_6mo']}, - user: {type: 'String', ref: 'User'} +import mongoose from 'mongoose'; +import _ from 'lodash'; +import shared from '../../../common'; +import couponCode from 'coupon-code'; +import baseModel from '../libs/api-v3/baseModel'; +import { + BadRequest, + NotAuthorized, +} from '../libs/api-v3/errors'; + +export let schema = new mongoose.Schema({ + event: {type: String, enum: ['wondercon', 'google_6mo']}, + user: {type: String, ref: 'User'}, }); -CouponSchema.statics.generate = function(event, count, callback) { - async.times(count, function(n,cb){ - mongoose.model('Coupon').create({event: event}, cb); - }, callback); -} - -CouponSchema.statics.apply = function(user, code, next){ - async.auto({ - get_coupon: function (cb) { - mongoose.model('Coupon').findById(cc.validate(code), cb); - }, - apply_coupon: ['get_coupon', function (cb, results) { - if (!results.get_coupon) return cb("Invalid coupon code"); - if (results.get_coupon.user) return cb("Coupon already used"); - switch (results.get_coupon.event) { - case 'wondercon': - user.items.gear.owned.eyewear_special_wondercon_red = true; - user.items.gear.owned.eyewear_special_wondercon_black = true; - user.items.gear.owned.back_special_wondercon_black = true; - user.items.gear.owned.back_special_wondercon_red = true; - user.items.gear.owned.body_special_wondercon_red = true; - user.items.gear.owned.body_special_wondercon_black = true; - user.items.gear.owned.body_special_wondercon_gold = true; - user.extra = {signupEvent: 'wondercon'}; - user.save(cb); - break; - } - }], - expire_coupon: ['apply_coupon', function (cb, results) { - results.get_coupon.user = user._id; - results.get_coupon.save(cb); - }] - }, function(err, results){ - if (err) return next(err); - next(null,results.apply_coupon[0]); - }) -} - -CouponSchema.plugin(autoinc.plugin, { - model: 'Coupon', - field: 'seq' +schema.plugin(baseModel, { + timestamps: true, }); -module.exports.schema = CouponSchema; -module.exports.model = mongoose.model("Coupon", CouponSchema); +// Add _id field after plugin to override default _id format +schema.add({ + _id: {type: String, default: couponCode.generate}, +}); + +schema.statics.generate = async function generateCoupons (event, count = 1) { + let coupons = _.times(count, () => { + return {event}; + }); + + return await this.create(coupons); +}; + +schema.statics.apply = async function applyCoupon (user, req, code) { + let coupon = await this.findById(couponCode.validate(code)).exec(); + if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon', req.language)); + if (coupon.user) throw new NotAuthorized(shared.i18n.t('couponUsed', req.language)); + + if (coupon.event === 'wondercon') { + user.items.gear.owned.eyewear_special_wondercon_red = true; + user.items.gear.owned.eyewear_special_wondercon_black = true; + user.items.gear.owned.back_special_wondercon_black = true; + user.items.gear.owned.back_special_wondercon_red = true; + user.items.gear.owned.body_special_wondercon_red = true; + user.items.gear.owned.body_special_wondercon_black = true; + user.items.gear.owned.body_special_wondercon_gold = true; + user.extra = {signupEvent: 'wondercon'}; + } + + await user.save(); + coupon.user = user._id; + await coupon.save(); +}; + +module.exports.schema = schema; +export let model = mongoose.model('Coupon', schema);