diff --git a/.drone.yml b/.drone.yml index 6881b39e..dff232a3 100644 --- a/.drone.yml +++ b/.drone.yml @@ -5,7 +5,7 @@ name: default steps: - name: setup - image: node:13 + image: node:14 when: event: - push @@ -16,7 +16,7 @@ steps: - clone - name: lint - image: node:13 + image: node:14 when: event: - push @@ -27,7 +27,7 @@ steps: - yarn lint - name: test - image: node:13 + image: node:14 when: event: - push @@ -38,7 +38,7 @@ steps: - MONGO_URL=mongodb://mongodb:27017/vote-test REDIS_URL=redis yarn mocha - name: coverage - image: node:13 + image: node:14 when: event: - pull_request @@ -54,7 +54,7 @@ steps: COVERALLS_SERVICE_NUMBER: ${DRONE_BUILD_NUMBER} - name: build - image: node:13 + image: node:14 when: event: - push @@ -114,7 +114,7 @@ steps: services: - name: mongodb - image: mongo:3.6 + image: mongo:4.4 - name: redis - image: redis:latest + image: redis:6.0 diff --git a/.eslintrc b/.eslintrc index fddfa21c..0e477f5c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,6 +3,7 @@ "eslint:recommended", "prettier" ], + "parser": "babel-eslint", "parserOptions": { "ecmaVersion": 8, "sourceType": "module" diff --git a/.gitignore b/.gitignore index ff2e3b7e..61f4c8eb 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ public/*.js public/main.css screenshots *.mp4 +client_secret.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..26bb9fcf --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +build/Release +node_modules +*.map +dist +public +app/stv/stv.js diff --git a/Dockerfile b/Dockerfile index f5232c15..ab3795eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:13 +FROM node:14-slim MAINTAINER Abakus Webkom # Create app directory diff --git a/README.md b/README.md index a752ab0b..a9a7914b 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,41 @@ # vote [![DroneCI](https://ci.webkom.dev/api/badges/webkom/vote/status.svg?branch=master)](https://ci.webkom.dev/webkom/vote) [![Coverage Status](https://coveralls.io/repos/github/webkom/vote/badge.svg?branch=master)](https://coveralls.io/github/webkom/vote?branch=master) [![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/webkom/vote)](https://libraries.io/github/webkom/vote#dependencies) ![GitHub](https://img.shields.io/github/license/webkom/vote) -> vote optimizes the election +> Digital voting system for Abakus' generral assembly -Digital voting system for Abakus' general assembly, built using the MEAN-stack (mongoDB, Express, AngularJS, Node.js). -Relevant (Norwegian) blog post: http://webkom.abakus.no/vote/ +Irrelevant [blog post](http://webkom.abakus.no/vote/) -![vote](http://i.imgur.com/DU1CXQx.png) +![vote](https://i.imgur.com/DIMAJfj.png) ## Setup -vote assumes you have a MongoDB-server running on `mongodb://localhost:27017/vote` and a redis-server running as `localhost:6379`. To -change the URL, export `MONGO_URL` and `REDIS_URL` as an environment variable. +vote assumes you have a MongoDB-server running on `mongodb://localhost:27017/vote` and a redis-server running as `localhost:6379`. To change the URL, export `MONGO_URL` and `REDIS_URL` as an environment variable. ```bash -$ git clone git@github.com:webkom/vote.git -$ cd vote - # Start MongoDB and Redis, both required for development and production $ docker-compose up -d - # Install all dependencies -$ yarn - -# Create a user via the CLI. You are promted to select usertype. -$ ./bin/users create-user +$ yarn && yarn start ``` ## Usage -vote uses a RFID-reader to register and activate/deactivate users. This is done to make sure that only people that are at the location can vote. The RFID-reader needs to be connected to the computer that is logged in to the moderator panel. +#### Users -An example deployment can be found in the `./deployment` folder. +Initially you will need to create a moderator and or admin user in order to login + +```bash +# Create a user via the CLI. You are prompted to select usertype. +$ ./bin/users create-user +``` + +#### Card-readers + +vote uses a RFID-reader to register and activate/deactivate users. This is done to make sure that only people that are at the location can vote. The RFID-reader needs to be connected to the computer that is logged in to the moderator panel. See section about using the card reader further down this readme. ### Development +> Check docs for the environment variable `ETHEREAL` if you intend to develop email related features + ```bash $ yarn start ``` @@ -46,25 +48,40 @@ $ yarn start - `REDIS_URL` - Hostname of the redis server - `default`: `localhost` -- `LOGO_SRC` _(optional)_ - - Url to the main logo on all pages +- `ICON_SRC` _(optional)_ + - Url to the main icon on all pages - `default`: `/static/images/Abakule.jpg` - `COOKIE_SECRET` - **IMPORTANT** to change this to a secret value in production!! - `default`: in dev: `localsecret`, otherwise empty - -See `app.js` for the rest +- `FRONTEND_URL` + - The site where vote should run + - `defualt`: `http://localhost:3000` +- `FROM` + - The name we send mail from + - `default`: `Abakus` +- `FROM_MAIL` + - The email we send mail from + - `default`: `admin@abakus.no` +- `SMTP_URL` + - An SMTP connection string of the form `smtps://username:password@smtp.example.com/?pool=true` +- `GOOGLE_AUTH` + - A base64 encoded string with the json data of a service account that can send mail. + +See `app.js` and `env.js` for the rest ### Production +> For a production deployment example, see [deployment](./deployment/README.md) in the `deployment` folder + ```bash $ yarn build -$ LOGO_SRC=https://my-domain.tld/logo.png NODE_ENV=production yarn start +$ ICON_SRC=https://some-domain/image.png NODE_ENV=production GOOGLE_AUTH=base64encoding yarn start ``` ## Using the card-readers -Make sure you have enabled Experimental Web Platform features and are using Google Chrome. Experimental features can be enabled by navigating to: chrome://flags/#enable-experimental-web-platform-features. +Make sure you have enabled Experimental Web Platform features and are using Google Chrome. Experimental features can be enabled by navigating to: **chrome://flags/#enable-experimental-web-platform-features**. Please check that the USB card reader is connected. When prompted for permissions, please select the card reader (CP210x). ### Serial permissions (Linux) @@ -105,7 +122,7 @@ $ yarn test $ HEADLESS=true yarn test ``` -## Vote occasion +## Vote Occasion We have a list of every occasion vote has been used. If you or your organization use vote for your event we would love if you made a PR where you append your event to the list. diff --git a/app.js b/app.js index 5234adbc..6da229c2 100644 --- a/app.js +++ b/app.js @@ -26,6 +26,7 @@ mongoose.connect(app.get('mongourl'), { useCreateIndex: true, useUnifiedTopology: true, useNewUrlParser: true, + useFindAndModify: true, }); raven.config(env.RAVEN_DSN).install(); @@ -66,10 +67,10 @@ app.use( resave: false, }) ); -const { LOGO_SRC, NODE_ENV } = env; +const { ICON_SRC, NODE_ENV } = env; app.locals = Object.assign({}, app.locals, { NODE_ENV, - LOGO_SRC, + ICON_SRC, }); /* istanbul ignore if */ diff --git a/app/controllers/election.js b/app/controllers/election.js index 2c4a68f4..d96b40c4 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -1,6 +1,7 @@ const Bluebird = require('bluebird'); const mongoose = require('mongoose'); const Election = require('../models/election'); +const User = require('../models/user'); const Alternative = require('../models/alternative'); const errors = require('../errors'); const app = require('../../app'); @@ -17,20 +18,46 @@ exports.load = (req, res, next, electionId) => }); exports.retrieveActive = (req, res) => - Election.findOne({ active: true }) - .where('hasVotedUsers.user') - .ne(req.user.id) + Election.findOne({ + active: true, + hasVotedUsers: { $ne: req.user._id }, + }) .select('-hasVotedUsers') .populate('alternatives') .exec() - .then((election) => { - res.status(200).json(election); + .then(async (election) => { + const { user, query } = req; + // There is no active election (that the user has not voted on) + if (!election) { + throw new errors.NotFoundError('election'); + } + + // User is active, return the election + if (user.active) { + return res.status(200).json(election); + } + + // Active election but wrong or not access code submitted, + // so we return 403 which prompts a access code input field. + if ( + !query.accessCode || + election.accessCode !== Number(query.accessCode) + ) { + throw new errors.AccessCodeError(); + } + + // Active election and the inactive user has the correct access code. + // Therefore we activate the users account, and return the elction. + await User.findByIdAndUpdate({ _id: user._id }, { active: true }); + return res.status(200).json(election); }); exports.create = (req, res) => Election.create({ title: req.body.title, description: req.body.description, + seats: req.body.seats, + useStrict: req.body.useStrict, }) .then((election) => { const alternatives = req.body.alternatives; @@ -69,20 +96,27 @@ function setElectionStatus(req, res, active) { return req.election.save(); } -exports.activate = (req, res) => - setElectionStatus(req, res, true).then((election) => { +exports.activate = async (req, res) => { + const otherActiveElection = await Election.findOne({ active: true }); + if (otherActiveElection) { + throw new errors.AlreadyActiveElectionError(); + } + return setElectionStatus(req, res, true).then((election) => { const io = app.get('io'); io.emit('election'); return res.status(200).json(election); }); +}; exports.deactivate = (req, res) => - setElectionStatus(req, res, false).then((election) => - res.status(200).json(election) - ); + setElectionStatus(req, res, false).then((election) => { + const io = app.get('io'); + io.emit('election'); + res.status(200).json(election); + }); -exports.sumVotes = (req, res) => - req.election.sumVotes().then((alternatives) => res.json(alternatives)); +exports.elect = (req, res) => + req.election.elect().then((result) => res.json(result)); exports.delete = (req, res) => { if (req.election.active) { diff --git a/app/controllers/register.js b/app/controllers/register.js new file mode 100644 index 00000000..3dfb3343 --- /dev/null +++ b/app/controllers/register.js @@ -0,0 +1,31 @@ +const Register = require('../models/register'); +const ObjectId = require('mongoose').Types.ObjectId; +const errors = require('../errors'); + +exports.list = (req, res) => + Register.find().then((register) => res.json(register)); + +exports.delete = async (req, res) => { + if (!ObjectId.isValid(req.params.registerId)) { + throw new errors.ValidationError('Invalid ObjectID'); + } + + const register = await Register.findOne({ + _id: req.params.registerId, + }); + + if (!register) { + throw new errors.NotFoundError('register'); + } + + if (!register.user) { + throw new errors.NoAssociatedUserError(); + } + + return register.remove().then(() => + res.status(200).json({ + message: 'Register and associated user deleted.', + status: 200, + }) + ); +}; diff --git a/app/controllers/user.js b/app/controllers/user.js index 36fe596e..f152ca50 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -1,8 +1,13 @@ const mongoose = require('mongoose'); const User = require('../models/user'); +const Register = require('../models/register'); const errors = require('../errors'); const errorChecks = require('../errors/error-checks'); +const crypto = require('crypto'); +const { mailHandler } = require('../digital/mail'); +const short = require('short-uuid'); + exports.count = async (req, res) => { const query = { admin: false, moderator: false }; if (req.query.active === 'true') { @@ -39,6 +44,95 @@ exports.create = (req, res) => { }); }; +exports.generate = async (req, res) => { + const { identifier, email, ignoreExistingUser } = req.body; + + if (!identifier) throw new errors.InvalidPayloadError('identifier'); + if (!email) throw new errors.InvalidPayloadError('email'); + + // Try to fetch an entry from the register with this username + const entry = await Register.findOne({ identifier }).exec(); + + if (entry && ignoreExistingUser) { + return res.status(409).json(identifier); + } + + // Entry has no user this user is allready activated + if (entry && !entry.user) { + return mailHandler('reject', { email }) + .then(() => + res.status(409).json({ + status: 'allready signed in', + user: identifier, + }) + ) + .catch((err) => { + throw new errors.MailError(err); + }); + } + + const password = crypto.randomBytes(11).toString('hex'); + + // Entry has a user but has not activated + if (entry && entry.user) { + const fetchedUser = await User.findByIdAndUpdate({ _id: entry.user }); + // Use the register function to "re-register" the user with a new password + return User.register(fetchedUser, password).then((updatedUser) => + mailHandler('resend', { email, username: updatedUser.username, password }) + .then(() => { + entry.email = email; + return entry.save(); + }) + .then(() => + res.status(201).json({ + status: 'regenerated', + user: identifier, + }) + ) + .catch((err) => { + throw new errors.MailError(err); + }) + ); + } + + // The user does not exist, so we generate as usual + const username = short.generate(); + const cardKey = short.generate(); + const userObject = { + username, + password, + cardKey, + active: false, + }; + + const user = new User(userObject); + return User.register(user, password) + .then((createdUser) => + mailHandler('send', { email, username: createdUser.username, password }) + .then(() => new Register({ identifier, email, user }).save()) + .then(() => + res.status(201).json({ status: 'generated', user: identifier }) + ) + .catch((err) => { + throw new errors.MailError(err); + }) + ) + .catch(mongoose.Error.ValidationError, (err) => { + throw new errors.ValidationError(err.errors); + }) + .catch(errorChecks.DuplicateError, (err) => { + if (err.message.includes('cardKey')) { + throw new errors.DuplicateCardError(); + } + + throw new errors.DuplicateUsernameError(); + }) + .catch(errorChecks.BadRequestError, (err) => { + // Comment to make git diff not be dumb + throw new errors.InvalidRegistrationError(err.message); + }); +}; + exports.toggleActive = async (req, res) => { const user = await User.findOne({ cardKey: req.params.cardKey }); if (!user) { diff --git a/app/controllers/vote.js b/app/controllers/vote.js index cec83ca5..bff2cfbd 100644 --- a/app/controllers/vote.js +++ b/app/controllers/vote.js @@ -1,26 +1,50 @@ -const mongoose = require('mongoose'); -const Alternative = require('../models/alternative'); +const Election = require('../models/election'); const Vote = require('../models/vote'); const errors = require('../errors'); -exports.create = (req, res) => { - const alternativeId = req.body.alternativeId; - if (!alternativeId) { - throw new errors.InvalidPayloadError('alternativeId'); +const env = require('../../env'); +const redisClient = require('redis').createClient(6379, env.REDIS_URL); +const Redlock = require('redlock'); +const redlock = new Redlock([redisClient], {}); + +exports.create = async (req, res) => { + const { election, priorities } = req.body; + const { user } = req; + + if (typeof election !== 'object' || Array.isArray(election)) { + throw new errors.InvalidPayloadError('election'); + } + + if (!Array.isArray(priorities)) { + throw new errors.InvalidPayloadError('priorities'); } - return Alternative.findById(alternativeId) - .populate('votes') - .exec() - .then((alternative) => { - if (!alternative) throw new errors.NotFoundError('alternative'); - return alternative.addVote(req.user); + // Create a new lock for this user to ensure nobody double-votes + const lock = await redlock.lock('vote:' + user._id, 1000); + return Election.findById(req.body.election._id) + .then(async (election) => { + // Election does not exist + if (!election) throw new errors.NotFoundError('election'); + + // Priorities cant be longer then alternatives + if (priorities.length > election.alternatives.length) { + throw new errors.InvalidPrioritiesLengthError(priorities, election); + } + + // Payload has priorites that are not in the election alternatives + const diff = priorities.filter( + (x) => !election.alternatives.includes(x._id) + ); + if (diff.length > 0) { + throw new errors.InvalidPriorityError(diff[0], election); + } + const vote = await election.addVote(user, priorities); + // Unlock when voted + await lock.unlock(); + + return vote; }) - .then((vote) => vote.populate('alternative').execPopulate()) - .then((vote) => res.status(201).send(vote)) - .catch(mongoose.Error.CastError, (err) => { - throw new errors.NotFoundError('alternative'); - }); + .then((vote) => res.status(201).json(vote)); }; exports.retrieve = async (req, res) => { @@ -30,11 +54,10 @@ exports.retrieve = async (req, res) => { throw new errors.MissingHeaderError('Vote-Hash'); } - const vote = await Vote.findOne({ hash: hash }).populate({ - path: 'alternative', - populate: { path: 'election', select: 'title _id' }, - }); + const vote = await Vote.findOne({ hash: hash }) + .populate('priorities') + .populate('election', 'title _id'); if (!vote) throw new errors.NotFoundError('vote'); - res.json(vote); + res.status(200).json(vote); }; diff --git a/app/digital/mail.js b/app/digital/mail.js new file mode 100644 index 00000000..29ac9df2 --- /dev/null +++ b/app/digital/mail.js @@ -0,0 +1,107 @@ +const nodemailer = require('nodemailer'); +const env = require('../../env'); +const fs = require('fs'); +const path = require('path'); +const handlebars = require('handlebars'); + +let creds = {}; +let transporter = null; + +// Mail transporter object Google service account +if (env.GOOGLE_AUTH) { + // Get google auth creds from env + creds = JSON.parse(Buffer.from(env.GOOGLE_AUTH, 'base64').toString()); + transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 465, + secure: true, + auth: { + type: 'OAuth2', + user: env.FROM_MAIL, + serviceClient: creds.client_id, + privateKey: creds.private_key, + }, + }); + transporter.verify(function (error, success) { + if (error) { + console.log('SMTP connection error', error); // eslint-disable-line no-console + } else { + console.log('SMTP connection success', success); // eslint-disable-line no-console + } + }); +} + +// Mail transporter if STMP connection string is used +if (env.SMTP_URL) { + transporter = nodemailer.createTransport(env.SMTP_URL); + transporter.verify(function (error, success) { + if (error) { + console.log('SMTP connection error', error); // eslint-disable-line no-console + } else { + console.log('SMTP connection success', success); // eslint-disable-line no-console + } + }); +} + +exports.mailHandler = async (action, data) => { + const html = fs.readFileSync( + path.resolve(__dirname, './template.html'), + 'utf8' + ); + const template = handlebars.compile(html); + let { email, username, password } = data; + username = username && username.replace(/\W/g, ''); + password = password && password.replace(/\W/g, ''); + + let replacements = { + from: env.FROM, + username, + password, + link: `${env.FRONTEND_URL}/auth/login?token=${username}:${password}:`, + }; + switch (action) { + case 'reject': + replacements = { + ...replacements, + new: false, + title: 'Allerede aktivert bruker!', + }; + break; + case 'resend': + replacements = { + ...replacements, + new: true, + title: 'Velkommen til Genfors!', + }; + break; + case 'send': + replacements = { + ...replacements, + new: true, + title: 'Velkommen til Genfors!', + }; + break; + } + const templatedHTML = template(replacements); + + // If we have not set any custom transporter we just use console mail + // We do this after the the templating to make sure the template still + // works even tho we dont use it. + if (!transporter) { + return new Promise(function (resolve, _) { + // Don't log all the console mail when running tests + if (process.env.NODE_ENV != 'test') { + console.log('MAIL:', action, data); // eslint-disable-line no-console + } + resolve('Done'); + }); + } + + return transporter.sendMail({ + from: `VOTE - ${process.env.FROM} <${process.env.FROM_MAIL}>`, + to: `${email}`, + subject: `VOTE Login Credentials`, + text: `Username: ${username}, Password: ${password}`, + html: templatedHTML, + }); +}; diff --git a/app/digital/template.html b/app/digital/template.html new file mode 100644 index 00000000..14b592a2 --- /dev/null +++ b/app/digital/template.html @@ -0,0 +1,229 @@ + + + + + + {{ title }} | {{ site }} + + + + + + + + + + + + + + + +
+
+ + + + +

{{title}}

+
+ {{from}} - VOTE +
+

{{description}}

+ {{#if new}} +

Dette er din digitale bruker. Under finner du brukernavn og passord.

+
+

Brukernavn: {{username}}

+

Password: {{password}}

+ Logg inn +
+ {{/if}} + {{#unless new}} +
+

Du har allerede motatt en bruker, og vi har registrert at du har klart å logge inn. Det vil si at du ikke vi få tilsendt en nytt brukernavn og passord. Ta kontakt med webkom dersom du mener dette er feil. +


+ {{/unless}} +
+
+ + diff --git a/app/errors/index.js b/app/errors/index.js index 6749f9e5..75d2ea87 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -64,6 +64,39 @@ class InvalidPayloadError extends Error { exports.InvalidPayloadError = InvalidPayloadError; +class AccessCodeError extends Error { + constructor() { + super(); + this.name = 'AccessCodeError'; + this.message = 'Incorrect accesscode supplied'; + this.status = 403; + } +} + +exports.AccessCodeError = AccessCodeError; + +class InvalidPriorityError extends Error { + constructor() { + super(); + this.name = 'InvalidPriorityError'; + this.message = `One or more alternatives does not exist on election.`; + this.status = 400; + } +} + +exports.InvalidPriorityError = InvalidPriorityError; + +class InvalidPrioritiesLengthError extends Error { + constructor(priorities, election) { + super(); + this.name = 'InvalidPrioritiesLengthError'; + this.message = `Priorities is of length ${priorities.length}, election has ${election.alternatives.length} alternatives.`; + this.status = 400; + } +} + +exports.InvalidPrioritiesLengthError = InvalidPrioritiesLengthError; + class MissingHeaderError extends Error { constructor(header) { super(); @@ -170,6 +203,50 @@ class DuplicateUsernameError extends Error { exports.DuplicateUsernameError = DuplicateUsernameError; +class DuplicateIdentifierError extends Error { + constructor() { + super(); + this.name = 'DuplicateIdentifierError'; + this.message = 'This identifier has allready gotten a user.'; + this.status = 409; + } +} + +exports.DuplicateIdentifierError = DuplicateIdentifierError; + +class AlreadyActiveElectionError extends Error { + constructor() { + super(); + this.name = 'AlreadyActiveElection'; + this.message = 'There is already an active election'; + this.status = 409; + } +} + +exports.AlreadyActiveElectionError = AlreadyActiveElectionError; + +class MailError extends Error { + constructor(err) { + super(); + this.name = 'MailError'; + this.message = `Something went wrong with the email. Err: ${err}`; + this.status = 500; + } +} + +exports.MailError = MailError; + +class NoAssociatedUserError extends Error { + constructor() { + super(); + this.name = 'NoAssociatedUserError'; + this.message = "Can't delete a register with no associated user"; + this.status = 400; + } +} + +exports.NoAssociatedUserError = NoAssociatedUserError; + exports.handleError = (res, err, status) => { const statusCode = status || err.status || 500; return res.status(statusCode).json( diff --git a/app/models/alternative.js b/app/models/alternative.js index d0500ec8..ea4d3a97 100644 --- a/app/models/alternative.js +++ b/app/models/alternative.js @@ -1,16 +1,4 @@ -const _ = require('lodash'); -const Bluebird = require('bluebird'); -const crypto = require('crypto'); const mongoose = require('mongoose'); -const Election = require('./election'); -const Vote = require('./vote'); -const errors = require('../errors'); -const env = require('../../env'); - -const redisClient = require('redis').createClient(6379, env.REDIS_URL); -const Redlock = require('redlock'); - -const redlock = new Redlock([redisClient], {}); const Schema = mongoose.Schema; @@ -25,49 +13,4 @@ const alternativeSchema = new Schema({ }, }); -alternativeSchema.pre('remove', function (next) { - return Vote.find({ alternative: this.id }) - .then((votes) => - Bluebird.map(votes, ( - vote // Have to call remove on each document to activate Vote's - ) => - // remove-middleware - vote.remove() - ) - ) - .nodeify(next); -}); - -alternativeSchema.methods.addVote = async function (user) { - if (!user) throw new Error("Can't vote without a user"); - if (!user.active) throw new errors.InactiveUserError(user.username); - if (user.admin) throw new errors.AdminVotingError(); - if (user.moderator) throw new errors.ModeratorVotingError(); - - const lock = await redlock.lock('vote:' + user.username, 2000); - const election = await Election.findById(this.election).exec(); - if (!election.active) { - await lock.unlock(); - throw new errors.InactiveElectionError(); - } - const votedUsers = election.hasVotedUsers.toObject(); - const hasVoted = _.find(votedUsers, { user: user._id }); - if (hasVoted) { - await lock.unlock(); - throw new errors.AlreadyVotedError(); - } - - // 24 character random string - const voteHash = crypto.randomBytes(12).toString('hex'); - const vote = new Vote({ hash: voteHash, alternative: this.id }); - - election.hasVotedUsers.push({ user: user._id }); - await election.save(); - const savedVote = await vote.save(); - - await lock.unlock(); - - return savedVote; -}; - module.exports = mongoose.model('Alternative', alternativeSchema); diff --git a/app/models/election.js b/app/models/election.js index ea49e69f..0bdcbf6a 100644 --- a/app/models/election.js +++ b/app/models/election.js @@ -1,15 +1,11 @@ +const _ = require('lodash'); const Bluebird = require('bluebird'); const errors = require('../errors'); const mongoose = require('mongoose'); const Vote = require('./vote'); const Schema = mongoose.Schema; - -const hasVotedSchema = new Schema({ - user: { - type: Schema.Types.ObjectId, - ref: 'User', - }, -}); +const stv = require('../stv/stv.js'); +const crypto = require('crypto'); const electionSchema = new Schema({ title: { @@ -20,43 +16,92 @@ const electionSchema = new Schema({ description: { type: String, }, + active: { + type: Boolean, + default: false, + }, + hasVotedUsers: [ + { + type: Schema.Types.ObjectId, + ref: 'User', + }, + ], alternatives: [ { type: Schema.Types.ObjectId, ref: 'Alternative', }, ], - active: { + seats: { + type: Number, + default: 1, + min: [1, 'An election should have at least one seat'], + }, + votes: [ + { + type: Schema.Types.ObjectId, + ref: 'Vote', + }, + ], + useStrict: { type: Boolean, default: false, + validate: { + validator: function (v) { + return v && this.seats !== 1 ? false : true; + }, + message: 'Strict elections must have exactly one seat', + }, + }, + accessCode: { + type: Number, + // https://mongoosejs.com/docs/defaults.html#default-functions + default: () => Math.floor(Math.random() * 9000 + 1000), }, - hasVotedUsers: [hasVotedSchema], }); electionSchema.pre('remove', function (next) { // Use mongoose.model getter to avoid circular dependencies - return mongoose + mongoose .model('Alternative') .find({ election: this.id }) - .then((alternatives) => - // Have to call remove on each document to activate Alternative's remove-middleware - Bluebird.map(alternatives, (alternative) => alternative.remove()) - ) + .then((alternatives) => { + Bluebird.map(alternatives, (alternative) => alternative.remove()); + }) + .nodeify(next); + mongoose + .model('Vote') + .find({ election: this.id }) + .then((votes) => { + Bluebird.map(votes, (vote) => vote.remove()); + }) .nodeify(next); }); -electionSchema.methods.sumVotes = function () { +electionSchema.methods.elect = async function () { if (this.active) { throw new errors.ActiveElectionError( 'Cannot retrieve results on an active election.' ); } - return Bluebird.map(this.alternatives, (alternativeId) => - Vote.find({ alternative: alternativeId }).then((votes) => ({ - alternative: alternativeId, - votes: votes.length, - })) + await this.populate('alternatives') + .populate({ + path: 'votes', + model: 'Vote', + populate: { + path: 'priorities', + model: 'Alternative', + }, + }) + .execPopulate(); + + const cleanElection = this.toJSON(); + return stv.calculateWinnerUsingSTV( + cleanElection.votes, + cleanElection.alternatives, + cleanElection.seats, + cleanElection.useStrict ); }; @@ -68,4 +113,39 @@ electionSchema.methods.addAlternative = async function (alternative) { return savedAlternative; }; +electionSchema.methods.addVote = async function (user, priorities) { + if (!user) throw new Error("Can't vote without a user"); + if (!user.active) throw new errors.InactiveUserError(user.username); + if (user.admin) throw new errors.AdminVotingError(); + if (user.moderator) throw new errors.ModeratorVotingError(); + + if (!this.active) { + throw new errors.InactiveElectionError(); + } + const votedUsers = this.hasVotedUsers.toObject(); + const hasVoted = _.find(votedUsers, { _id: user._id }); + + if (hasVoted) { + throw new errors.AlreadyVotedError(); + } + + // 24 character random string + const voteHash = crypto.randomBytes(12).toString('hex'); + const vote = new Vote({ + hash: voteHash, + election: this.id, + priorities: priorities, + }); + + this.hasVotedUsers.push(user._id); + await this.save(); + + const savedVote = await vote.save(); + this.votes.push(savedVote._id); + + await this.save(); + + return savedVote; +}; + module.exports = mongoose.model('Election', electionSchema); diff --git a/app/models/register.js b/app/models/register.js new file mode 100644 index 00000000..de258b4f --- /dev/null +++ b/app/models/register.js @@ -0,0 +1,31 @@ +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const registerSchema = new Schema({ + identifier: { + type: String, + required: true, + unique: true, + }, + email: { + type: String, + required: true, + unique: true, + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User', + }, +}); + +// Delete the associated user when deleting a register entry +registerSchema.pre('remove', function (next) { + mongoose + .model('User') + .findOne({ _id: this.user }) + .then((user) => user.remove()) + .nodeify(next); +}); + +module.exports = mongoose.model('Register', registerSchema); diff --git a/app/models/user.js b/app/models/user.js index 324069e4..4521c663 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -38,8 +38,7 @@ const userSchema = new Schema({ }); userSchema.pre('save', function (next) { - // Usernames are case-insensitive, so store them - // in lowercase: + // Usernames are case-insensitive, so store them in lowercase: this.username = this.username.toLowerCase(); next(); }); diff --git a/app/models/vote.js b/app/models/vote.js index 9c28e01c..b11f8055 100644 --- a/app/models/vote.js +++ b/app/models/vote.js @@ -8,10 +8,16 @@ const voteSchema = new Schema({ required: true, index: true, }, - alternative: { + election: { type: Schema.Types.ObjectId, - ref: 'Alternative', + ref: 'Election', }, + priorities: [ + { + type: Schema.Types.ObjectId, + ref: 'Alternative', + }, + ], }); module.exports = mongoose.model('Vote', voteSchema); diff --git a/app/routes/api/election.js b/app/routes/api/election.js index 29829ec2..ed1fb0d6 100644 --- a/app/routes/api/election.js +++ b/app/routes/api/election.js @@ -28,6 +28,6 @@ router .get(alternative.list) .post(alternative.create); -router.get('/:electionId/votes', election.sumVotes); +router.get('/:electionId/votes', election.elect); module.exports = router; diff --git a/app/routes/api/index.js b/app/routes/api/index.js index 9e13f5cc..0354a569 100644 --- a/app/routes/api/index.js +++ b/app/routes/api/index.js @@ -3,6 +3,7 @@ const electionRoutes = require('./election'); const userRoutes = require('./user'); const voteRoutes = require('./vote'); const qrRoutes = require('./qr'); +const registerRoutes = require('./register'); const errors = require('../../errors'); router.use('/election', electionRoutes); @@ -10,6 +11,7 @@ router.use('/user', userRoutes); router.use('/alternative', electionRoutes); router.use('/vote', voteRoutes); router.use('/qr', qrRoutes); +router.use('/register', registerRoutes); router.use((req, res, next) => { const error = new errors.NotFoundError(req.originalUrl); diff --git a/app/routes/api/register.js b/app/routes/api/register.js new file mode 100644 index 00000000..cd9dc4ef --- /dev/null +++ b/app/routes/api/register.js @@ -0,0 +1,9 @@ +const router = require('express-promise-router')(); +const register = require('../../controllers/register'); +const ensureModerator = require('../helpers').ensureModerator; + +router.route('/').get(ensureModerator, register.list); + +router.route('/:registerId').delete(ensureModerator, register.delete); + +module.exports = router; diff --git a/app/routes/api/user.js b/app/routes/api/user.js index a976a055..560d8fb0 100644 --- a/app/routes/api/user.js +++ b/app/routes/api/user.js @@ -7,6 +7,8 @@ router .get(ensureModerator, user.list) .post(ensureModerator, user.create); +router.post('/generate', ensureModerator, user.generate); + router.get('/count', ensureModerator, user.count); router.put('/:username/change_card', ensureModerator, user.changeCard); diff --git a/app/routes/auth.js b/app/routes/auth.js index b246c6e3..418ad4a9 100644 --- a/app/routes/auth.js +++ b/app/routes/auth.js @@ -1,6 +1,7 @@ const router = require('express-promise-router')(); const passport = require('passport'); const errors = require('../errors'); +const Register = require('../models/register'); router.get('/login', (req, res) => { const csrfToken = process.env.NODE_ENV !== 'test' ? req.csrfToken() : 'test'; @@ -22,7 +23,9 @@ router.post( failureRedirect: '/auth/login', failureFlash: 'Brukernavn og/eller passord er feil.', }), - (req, res) => { + async (req, res) => { + // Set the Email index.user to null for the spesific email + await Register.findOneAndUpdate({ user: req.user._id }, { user: null }); // If the user tried to access a specific page before, redirect there: // TODO FIXME //const path = req.session.originalPath || '/'; diff --git a/app/stv/stv.js b/app/stv/stv.js new file mode 100644 index 00000000..2727f467 --- /dev/null +++ b/app/stv/stv.js @@ -0,0 +1,192 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const cloneDeep = require("lodash/cloneDeep"); +var Action; +(function (Action) { + Action["iteration"] = "ITERATION"; + Action["win"] = "WIN"; + Action["eliminate"] = "ELIMINATE"; + Action["multi_tie_eliminations"] = "MULTI_TIE_ELIMINATIONS"; + Action["tie"] = "TIE"; +})(Action || (Action = {})); +var Status; +(function (Status) { + Status["resolved"] = "RESOLVED"; + Status["unresolved"] = "UNRESOLVED"; +})(Status || (Status = {})); +const winningThreshold = (votes, seats, useStrict) => { + if (useStrict) { + return Math.floor((2 * votes.length) / 3) + 1; + } + return Math.floor(votes.length / (seats + 1) + 1); +}; +const EPSILON = 0.000001; +exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats = 1, useStrict = false) => { + const log = []; + let votes = inputVotes.map((vote) => ({ + _id: String(vote._id), + priorities: vote.priorities.map((vote) => ({ + _id: String(vote._id), + description: vote.description, + election: String(vote._id), + })), + hash: vote.hash, + weight: 1, + })); + let alternatives = inputAlternatives.map((alternative) => ({ + _id: String(alternative._id), + description: alternative.description, + election: String(alternative._id), + })); + const thr = winningThreshold(votes, seats, useStrict); + const blankVoteCount = inputVotes.filter((vote) => vote.priorities.length === 0).length; + const winners = []; + let iteration = 0; + while (votes.length > 0 && iteration < 100) { + iteration += 1; + votes = votes.filter((vote) => vote.priorities.length > 0); + const counts = alternatives.reduce((counts, alternative) => (Object.assign(Object.assign({}, counts), { [alternative.description]: 0 })), {}); + for (const i in votes) { + const vote = cloneDeep(votes[i]); + const currentAlternative = cloneDeep(vote.priorities[0]); + counts[currentAlternative.description] = + vote.weight + (counts[currentAlternative.description] || 0); + } + const iterationLog = { + action: Action.iteration, + iteration, + winners: winners.slice(), + counts: handleFloatsInOutput(counts), + }; + log.push(iterationLog); + const roundWinners = {}; + const excessFractions = {}; + const doneVotes = {}; + for (const i in alternatives) { + const alternative = cloneDeep(alternatives[i]); + const voteCount = counts[alternative.description] || 0; + if (voteCount >= thr - EPSILON) { + excessFractions[alternative._id] = (voteCount - thr) / voteCount; + roundWinners[alternative._id] = {}; + winners.push(alternative); + const winLog = { + action: Action.win, + alternative, + voteCount: Number(voteCount.toFixed(4)), + }; + log.push(winLog); + for (const i in votes) { + const vote = cloneDeep(votes[i]); + if (vote.priorities[0]._id === alternative._id) + doneVotes[i] = {}; + } + } + } + let doneAlternatives = {}; + let nextRoundVotes = []; + if (Object.keys(roundWinners).length > 0) { + if (winners.length === seats) { + return { + result: { status: Status.resolved, winners }, + log, + thr, + seats, + voteCount: inputVotes.length, + blankVoteCount, + useStrict, + }; + } + doneAlternatives = roundWinners; + nextRoundVotes = votes.filter((_, i) => !doneVotes[i]); + for (const i in doneVotes) { + const vote = cloneDeep(votes[i]); + const alternative = cloneDeep(vote.priorities[0]); + const fraction = excessFractions[alternative._id] || 0; + if (fraction === 0 || vote.priorities.length === 1) + continue; + vote['weight'] = vote.weight * fraction; + nextRoundVotes.push(vote); + } + } + else { + const minScore = Math.min(...alternatives.map((alternative) => counts[alternative.description] || 0)); + const minAlternatives = alternatives.filter((alternative) => (counts[alternative.description] || 0) <= minScore + EPSILON); + if (minAlternatives.length > 1) { + let reverseIteration = iteration; + const tieObject = { + action: Action.tie, + description: `There are ${minAlternatives.length} candidates with a score of ${Number(minScore.toFixed(4))} at iteration ${reverseIteration}`, + }; + log.push(tieObject); + while (reverseIteration >= 1) { + const logObject = log.find((entry) => entry.iteration === reverseIteration); + const iterationMinScore = Math.min(...minAlternatives.map((a) => logObject.counts[a.description] || 0)); + const iterationMinAlternatives = minAlternatives.filter((alternative) => (logObject.counts[alternative.description] || 0) <= + iterationMinScore + EPSILON); + if (reverseIteration === 1 && iterationMinAlternatives.length > 1) { + const backTrackFailed = { + action: Action.tie, + description: 'The backward checking went to iteration 1 without breaking the tie', + }; + log.push(backTrackFailed); + const multiTieElem = { + action: Action.multi_tie_eliminations, + alternatives: iterationMinAlternatives, + minScore: Number(minScore.toFixed(4)), + }; + log.push(multiTieElem); + iterationMinAlternatives.forEach((alternative) => (doneAlternatives[alternative._id] = {})); + break; + } + reverseIteration--; + if (iterationMinAlternatives.length > 1) + continue; + const minAlternative = iterationMinAlternatives[0]; + if (minAlternative) { + const elem = { + action: Action.eliminate, + alternative: minAlternative, + minScore: Number(iterationMinScore.toFixed(4)), + }; + log.push(elem); + doneAlternatives[minAlternative._id] = {}; + } + break; + } + } + else { + const minAlternative = minAlternatives[0]; + if (minAlternative) { + const elemLowest = { + action: Action.eliminate, + alternative: minAlternative, + minScore: Number(minScore.toFixed(4)), + }; + log.push(elemLowest); + doneAlternatives[minAlternative._id] = {}; + } + } + nextRoundVotes = votes; + } + votes = nextRoundVotes.map((vote) => { + vote['priorities'] = vote.priorities.filter((alternative) => !doneAlternatives[alternative._id]); + return vote; + }); + alternatives = alternatives.filter((alternative) => !doneAlternatives[alternative._id]); + } + return { + result: { status: Status.unresolved, winners }, + log, + thr, + seats, + voteCount: inputVotes.length, + blankVoteCount, + useStrict, + }; +}; +const handleFloatsInOutput = (obj) => { + const newObj = {}; + Object.entries(obj).forEach(([k, v]) => (newObj[k] = Number(v.toFixed(4)))); + return newObj; +}; +//# sourceMappingURL=stv.js.map \ No newline at end of file diff --git a/app/stv/stv.ts b/app/stv/stv.ts new file mode 100644 index 00000000..7175cd74 --- /dev/null +++ b/app/stv/stv.ts @@ -0,0 +1,433 @@ +import cloneDeep = require('lodash/cloneDeep'); + +// This is a TypeScript file in a JavaScript project so it must be complied +// If you make changes to this file it must be recomplied using `tsc` in +// order for the changes to be reflected in the rest of the program. +// +// app/models/election .elect() is the only file that uses this function +// and importes it from stv.js, which is the compiled result of this file. + +type STV = { + result: STVResult; + log: STVEvent[]; + thr: number; + seats: number; + voteCount: number; + blankVoteCount: number; + useStrict: boolean; +}; + +type Alternative = { + _id: string; + description: string; + election: string; +}; + +type STVCounts = { + [key: string]: number; +}; + +type Vote = { + _id: string; + priorities: Alternative[]; + hash: string; + weight: number; +}; + +enum Action { + iteration = 'ITERATION', + win = 'WIN', + eliminate = 'ELIMINATE', + multi_tie_eliminations = 'MULTI_TIE_ELIMINATIONS', + tie = 'TIE', +} + +type STVEvent = { + action: Action; + iteration?: number; + winners?: Alternative[]; + counts?: { [key: string]: number }; + alternative?: Alternative; + alternatives?: Alternative[]; + voteCount?: number; + minScore?: number; + description?: string; +}; + +interface STVEventIteration extends STVEvent { + action: Action.iteration; + iteration: number; + winners: Alternative[]; + counts: { [key: string]: number }; +} + +interface STVEventWin extends STVEvent { + action: Action.win; + alternative: Alternative; + voteCount: number; +} +interface STVEventEliminate extends STVEvent { + action: Action.eliminate; + alternative: Alternative; + minScore: number; +} +interface STVEventTie extends STVEvent { + action: Action.tie; + description: string; +} +interface STVEventMulti extends STVEvent { + action: Action.multi_tie_eliminations; + alternatives: Alternative[]; + minScore: number; +} + +enum Status { + resolved = 'RESOLVED', + unresolved = 'UNRESOLVED', +} + +type STVResult = + | { + status: Status; + winners: Alternative[]; + } + | { + status: Status; + winners: Alternative[]; + }; + +/** + * The Droop qouta https://en.wikipedia.org/wiki/Droop_quota + * @param votes - All votes for the election + * @param seats - The number of seats in this election + * @param useStrict - Sets the threshold to 67% no matter what + * + * @return The amount votes needed to be elected + */ +const winningThreshold = ( + votes: Vote[], + seats: number, + useStrict: boolean +): number => { + if (useStrict) { + return Math.floor((2 * votes.length) / 3) + 1; + } + return Math.floor(votes.length / (seats + 1) + 1); +}; + +// Epsilon value used in comparisons of floating point errors. See dataset5.js. +const EPSILON = 0.000001; + +/** + * Will calculate the election result using Single Transferable Vote + * @param votes - All votes for the election + * @param alternatives - All possible alternatives for the election + * @param seats - The number of seats in this election. Default 1 + * @param useStrict - This election will require a qualified majority. Default false + * + * @return The full election, including result, log and threshold value + */ +exports.calculateWinnerUsingSTV = ( + inputVotes: any, + inputAlternatives: any, + seats = 1, + useStrict = false +): STV => { + // Hold the log for the entire election + const log: STVEvent[] = []; + + // Stringify and clean the votes + let votes: Vote[] = inputVotes.map((vote: any) => ({ + _id: String(vote._id), + priorities: vote.priorities.map((vote: any) => ({ + _id: String(vote._id), + description: vote.description, + election: String(vote._id), + })), + hash: vote.hash, + weight: 1, + })); + + // Stringify and clean the alternatives + let alternatives: Alternative[] = inputAlternatives.map( + (alternative: any) => ({ + _id: String(alternative._id), + description: alternative.description, + election: String(alternative._id), + }) + ); + + // The threshold value needed to win + const thr: number = winningThreshold(votes, seats, useStrict); + + // The number of blank votes + const blankVoteCount = inputVotes.filter( + (vote: Vote) => vote.priorities.length === 0 + ).length; + + // Winners for the election + const winners: Alternative[] = []; + + // With each iteration we count the number of first place votes each candidate has. + let iteration = 0; + while (votes.length > 0 && iteration < 100) { + iteration += 1; + + // Remove empty votes after threshold in order to preserve "blank votes" + votes = votes.filter((vote: Vote) => vote.priorities.length > 0); + + // Dict with the counts for each candidate + const counts: STVCounts = alternatives.reduce( + (counts: STVCounts, alternative: Alternative) => ({ + ...counts, + [alternative.description]: 0, + }), + {} + ); + + for (const i in votes) { + // The vote for this loop + const vote = cloneDeep(votes[i]); + + // We always count the first value (priorities[0]) because there is a mutation step + // that removes values that are "done". These are values connected to candidates + // that have either won or been eliminated from the election. + const currentAlternative = cloneDeep(vote.priorities[0]); + + // Use the alternatives description as key in the counts, and add one for each count + counts[currentAlternative.description] = + vote.weight + (counts[currentAlternative.description] || 0); + } + + // Push Iteration to log + const iterationLog: STVEventIteration = { + action: Action.iteration, + iteration, + winners: winners.slice(), + counts: handleFloatsInOutput(counts), + }; + log.push(iterationLog); + + // Dict of winners + const roundWinners: { [key: string]: {} } = {}; + // Dict of excess fractions per key + const excessFractions: { [key: string]: number } = {}; + // Dict of done votes + const doneVotes: { [key: number]: {} } = {}; + + // Loop over the different alternatives + for (const i in alternatives) { + // Get an alternative + const alternative: Alternative = cloneDeep(alternatives[i]); + // Find the number of votes for this alternative + const voteCount: number = counts[alternative.description] || 0; + + // If an alternative has enough votes, add them as round winner + // Due to JavaScript float precision errors this voteCount is checked with a range + if (voteCount >= thr - EPSILON) { + // Calculate the excess fraction of votes, above the threshold + excessFractions[alternative._id] = (voteCount - thr) / voteCount; + + // Add the alternatives ID to the dict of winners this round + roundWinners[alternative._id] = {}; + + // Push the whole alternative to the list of new winners + winners.push(alternative); + + // Add the WIN action to the iteration log + const winLog: STVEventWin = { + action: Action.win, + alternative, + voteCount: Number(voteCount.toFixed(4)), + }; + log.push(winLog); + + // Find the done Votes + for (const i in votes) { + // The vote for this loop + const vote: Vote = cloneDeep(votes[i]); + + // Votes that have the winning alternative as their first pick + if (vote.priorities[0]._id === alternative._id) doneVotes[i] = {}; + } + } + } + + // Have won or been eliminated + let doneAlternatives: { [key: string]: {} } = {}; + + // The votes that will go on to the next round + let nextRoundVotes: Vote[] = []; + + // If there are new winners this round + if (Object.keys(roundWinners).length > 0) { + // Check STV can terminate and return the RESOLVED winners + if (winners.length === seats) { + return { + result: { status: Status.resolved, winners }, + log, + thr, + seats, + voteCount: inputVotes.length, + blankVoteCount, + useStrict, + }; + } + + // Set the done alternatives as the roundwinners + doneAlternatives = roundWinners; + + // The next rounds votes are votes that are not done. + nextRoundVotes = votes.filter((_, i) => !doneVotes[i]); + + // Go through all done votes + for (const i in doneVotes) { + // The vote for this loop + const vote: Vote = cloneDeep(votes[i]); + + // Take the first choice of the done vote + const alternative: Alternative = cloneDeep(vote.priorities[0]); + + // Find the excess fraction for this alternative + const fraction: number = excessFractions[alternative._id] || 0; + + // If the fraction is 0 (meaning no votes should be transferred) or if the vote + // has no more priorities (meaning it's exhausted) we can continue without transfer + if (fraction === 0 || vote.priorities.length === 1) continue; + + // Fractional transfer. We mutate the weight for these votes by a fraction + vote['weight'] = vote.weight * fraction; + // Push the mutated votes to the list of votes to be processed in the next iteration + nextRoundVotes.push(vote); + } + } else { + // Find the lowest score + const minScore: number = Math.min( + ...alternatives.map( + (alternative) => counts[alternative.description] || 0 + ) + ); + + // Find the candidates with the lowest score + const minAlternatives: Alternative[] = alternatives.filter( + (alternative) => + (counts[alternative.description] || 0) <= minScore + EPSILON + ); + + // There is a tie for eliminating candidates. Per Scottish STV we must look at the previous rounds + if (minAlternatives.length > 1) { + let reverseIteration = iteration; + + // Log the Tie + const tieObject: STVEventTie = { + action: Action.tie, + description: `There are ${ + minAlternatives.length + } candidates with a score of ${Number( + minScore.toFixed(4) + )} at iteration ${reverseIteration}`, + }; + log.push(tieObject); + + // As long as the reverseIteration is larger than 1 we can look further back + while (reverseIteration >= 1) { + // Find the log object for the last iteration + const logObject: STVEvent = log.find( + (entry: STVEventIteration) => entry.iteration === reverseIteration + ); + + // Find the lowest score (with regard to the alternatives in the actual iteration) + const iterationMinScore = Math.min( + ...minAlternatives.map((a) => logObject.counts[a.description] || 0) + ); + + // Find the candidates (in regard to the actual iteration) that has the lowest score + const iterationMinAlternatives = minAlternatives.filter( + (alternative: Alternative) => + (logObject.counts[alternative.description] || 0) <= + iterationMinScore + EPSILON + ); + + // If we are at iteration lvl 1 and there is still a tie we cannot do anything + if (reverseIteration === 1 && iterationMinAlternatives.length > 1) { + const backTrackFailed: STVEventTie = { + action: Action.tie, + description: + 'The backward checking went to iteration 1 without breaking the tie', + }; + log.push(backTrackFailed); + + // Eliminate all candidates that are in the last iterationMinAlternatives + const multiTieElem: STVEventMulti = { + action: Action.multi_tie_eliminations, + alternatives: iterationMinAlternatives, + minScore: Number(minScore.toFixed(4)), + }; + log.push(multiTieElem); + iterationMinAlternatives.forEach( + (alternative) => (doneAlternatives[alternative._id] = {}) + ); + break; + } + + reverseIteration--; + // If there is a tie at this iteration as well we must continue the loop + if (iterationMinAlternatives.length > 1) continue; + // There is only one candidate with the lowest score + const minAlternative = iterationMinAlternatives[0]; + if (minAlternative) { + const elem: STVEventEliminate = { + action: Action.eliminate, + alternative: minAlternative, + minScore: Number(iterationMinScore.toFixed(4)), + }; + log.push(elem); + doneAlternatives[minAlternative._id] = {}; + } + break; + } + } else { + // There is only one candidate with the lowest score + const minAlternative = minAlternatives[0]; + if (minAlternative) { + const elemLowest: STVEventEliminate = { + action: Action.eliminate, + alternative: minAlternative, + minScore: Number(minScore.toFixed(4)), + }; + log.push(elemLowest); + doneAlternatives[minAlternative._id] = {}; + } + } + nextRoundVotes = votes; + } + + // We filter out the alternatives of the doneAlternatives from the list of nextRoundVotes + votes = nextRoundVotes.map((vote) => { + vote['priorities'] = vote.priorities.filter( + (alternative) => !doneAlternatives[alternative._id] + ); + return vote; + }); + // Remove the alternatives that are done + alternatives = alternatives.filter( + (alternative) => !doneAlternatives[alternative._id] + ); + } + return { + result: { status: Status.unresolved, winners }, + log, + thr, + seats, + voteCount: inputVotes.length, + blankVoteCount, + useStrict, + }; +}; + +// Round floats to fixed in output +const handleFloatsInOutput = (obj: Object) => { + const newObj = {}; + Object.entries(obj).forEach(([k, v]) => (newObj[k] = Number(v.toFixed(4)))); + return newObj; +}; diff --git a/app/views/layout.pug b/app/views/layout.pug index ca2d3233..23cac7e6 100644 --- a/app/views/layout.pug +++ b/app/views/layout.pug @@ -23,7 +23,7 @@ html(ng-app='voteApp') header .container .row.header: .col-xs-12 - img(src=LOGO_SRC) + img(src=ICON_SRC) span vote .row: block navbar diff --git a/app/views/moderatorIndex.pug b/app/views/moderatorIndex.pug index bbaf0eea..02cc1658 100644 --- a/app/views/moderatorIndex.pug +++ b/app/views/moderatorIndex.pug @@ -9,12 +9,16 @@ block navbar ul.list-unstyled li a(href='/moderator/create_user') Registrer bruker + li + a(href='/moderator/generate_user') Generer bruker li a(href='/moderator/qr') QR li a(href='/moderator/activate_user') Aktiver bruker li a(href='/moderator/change_card') Mistet kort + li + a(href='/moderator/manage_register') Register li a(href='/moderator/deactivate_users') Deaktiver brukere diff --git a/app/views/partials/admin/createElection.pug b/app/views/partials/admin/createElection.pug index 3491cca1..66501cd1 100644 --- a/app/views/partials/admin/createElection.pug +++ b/app/views/partials/admin/createElection.pug @@ -24,6 +24,25 @@ ng-model='election.description' ) + .form-group(required) + label Plasser + input.form-control( + type='number', + name='seats', + placeholder='Antall plasser (vinnere)', + required='required', + ng-model='election.seats', + ng-min='1', + ng-max='election.alternatives.length', + ng-disabled='election.useStrict' + ) + p.text-danger(ng-show='createElectionForm.seats.$invalid') + | Antall plasser er ikke gyldig + br + | Må være minst 1 og maks {{ election.alternatives.length }} + p(ng-show='election.useStrict') + | Deaktiver absolutt flertall for å endre antall plasser + .alternatives.admin label Alternativer a.new-alternative(ng-click='addAlternative()') @@ -51,6 +70,20 @@ ng-show='alternativeForm.alternative{{$index}}.$invalid' ) Alternativ er påkrevd + .form-group + label Bruk absolutt flertall + input( + type='checkbox', + name='useStrict', + value='false', + ng-model='election.useStrict', + ng-disabled='election.seats != 1' + ) + br + p + | Krev 2/3 av stemmene for å vinne. + | Ellers brukes vanlig STV regler. Gir ikke mening for plasser > 1 + button#submit.btn.btn-default.btn-lg( type='submit', ng-disabled='createElectionForm.$invalid' diff --git a/app/views/partials/admin/editElection.pug b/app/views/partials/admin/editElection.pug index 39ae1695..087bfb58 100644 --- a/app/views/partials/admin/editElection.pug +++ b/app/views/partials/admin/editElection.pug @@ -3,6 +3,12 @@ .election-info.admin h2 {{ election.title }} p {{ election.description }} + h3 Tilgangskode: + span.access-code.mono {{ election.accessCode }} + i.fa.fa-copy.copy-icon.cs-tooltip( + ng-click='copyToClipboard(election.accessCode)' + ) + .cs-tooltiptext {{ copySuccess ? "Kopiert!" : "Kopier" }} .election-info.admin h3.user-status @@ -21,9 +27,9 @@ ul.list-unstyled li(ng-repeat='alternative in election.alternatives') - p {{ alternative.description }} - span(ng-if='showCount') - | {{ alternative.votes }} - {{ getPercentage(alternative.votes) }} % + .content + div + p {{ alternative.description }} form.add-alternative.form-group( name='alternativeForm', @@ -44,16 +50,70 @@ ng-if='!election.active', ng-disabled='alternativeForm.$invalid' ) Legg til alternativ - button.toggle-show.btn.btn-default( - type='button', - ng-click='toggleCount()', - ng-if='!election.active', - ng-class='{"alone": election.active}' - ) - | {{ showCount ? "Skjul resultat" : "Vis resultat" }} + button.toggle-show.btn.btn-default( type='button', ng-click='copyElection()', ng-if='!election.active' ) | Kopier avstemning + + button.toggle-show.btn.btn-default( + type='button', + ng-click='toggleResult()', + ng-if='!election.active', + ng-class='{"alone": election.active}' + ) + | {{ showResult ? "Fjern resultat" : "Kalkuler resultat" }} + + div(ng-if='showResult') + h2 Oppsummering + table.table.mono + tbody + tr + th.th-left Stemmer + th.th-right = + span(ng-bind='election.voteCount') {{ election.voteCount }} + tr + th.th-left ∟ Hvorav blanke stemmer + th.th-right = + span(ng-bind='election.blankVoteCount') {{ election.blankVoteCount }} + tr + th.th-left Plasser + th.th-right = + span(ng-bind='election.seats') {{ election.seats }} + tr + th.th-left Terskel + th.th-right ⌊ + span.cs-tooltip {{ election.voteCount }} + span.cs-tooltiptext Antall stemmer + span / + span.cs-tooltip {{ election.seats + 1 }} + span.cs-tooltiptext Antall plasser + 1 + span ⌋ + 1 = {{ election.thr }} + h2 Logg + ul.list-unstyled.log.mono + li(ng-repeat='elem in election.log', ng-switch='elem.action') + div(ng-switch-when='ITERATION') + h5 {{ elem.action }} {{ elem.iteration }} + p(ng-repeat='(key, value) in elem.counts') {{ key }} with {{ value }} votes + div(ng-switch-when='WIN') + h5 {{ elem.action }} + p Elected: {{ elem.alternative.description }} with {{ elem.voteCount }} votes + div(ng-switch-when='ELIMINATE') + h5 {{ elem.action }} + p Eliminated: {{ elem.alternative.description }} with {{ elem.minScore }} votes + div(ng-switch-when='MULTI_TIE_ELIMINATIONS') + h5 {{ elem.action }} + p(ng-repeat='alt in elem.alternatives') Eliminated: {{ alt.description }} with {{ elem.minScore }} votes + div(ng-switch-when='TIE') + h5 {{ elem.action }} + p {{ elem.description }} + hr + h2 Resultat + div(ng-class='\'alert-\' + election.status') {{ election.result.status }} + table.table.mono.large(style='margin-bottom: 100px;') + tbody + tr(ng-repeat='winner in election.result.winners') + th.th-right Vinner {{ $index + 1 }}: + th.th-left {{ winner.description }} diff --git a/app/views/partials/election.pug b/app/views/partials/election.pug index e56c2fcd..4dac7f05 100644 --- a/app/views/partials/election.pug +++ b/app/views/partials/election.pug @@ -1,23 +1,79 @@ -.center.text-center(ng-switch='!!activeElection') - div(ng-switch-when='true') - .election-info - h2 {{ activeElection.title }} - p {{ activeElection.description }} - - .alternatives - h3 Alternativer - ul.list-unstyled - li( - ng-repeat='alternative in activeElection.alternatives', - ng-click='selectAlternative(alternative)', - ng-class='{"selected": isChosen(alternative)}' - ) - p {{ alternative.description }} - - confirm-vote( - vote-handler='vote()', - selected-alternative='selectedAlternative' +.center.text-center(ng-switch='electionExists') + div(ng-switch-when='true', ng-switch='correctCode') + div(ng-switch-when='true', ng-switch='confirmVote') + div(ng-switch-when='false') + .election-info + h2 {{ activeElection.title }} + p {{ activeElection.description }} + + .alternatives + ul.list-unstyled + li( + ng-repeat='alternative in getPossibleAlternatives()', + ng-click='selectAlternative(alternative)' + ) + .content + p {{ alternative.description }} + .icon.add(ng-click='deselectAlternative(alternative._id)') + i.fa.fa-plus + + .alternatives + h3 Din prioritering + p.helptext(ng-if='priorities.length == 0') + em Velg et alternativ fra listen + + ul.list-unstyled.numbered( + sortable, + sortable-on-update='updatePriority', + sortable-list='priorities', + sortable-animation='100', + sortable-delay='0', + sortable-handle='.content' + ) + li(ng-repeat='alternative in priorities track by alternative._id') + .content + .drag + i.fa.fa-bars + div + p {{ alternative.description }} + .icon.remove(ng-click='deselectAlternative(alternative._id)') + i.fa.fa-close + + button.btn.btn-lg.btn-default(type='button', ng-click='confirm()') {{ priorities.length === 0 ? "Stem Blank" : "Avgi stemme" }} + + div(ng-switch-when='true') + h3 Bekreft din stemme + .confirmVotes(ng-switch='priorities.length === 0') + .ballot + div(ng-switch-when='true') + h3 Blank stemme + i Din stemme vil fortsatt påvirke hvor mange stemmer som kreves for å vinne + div(ng-switch-when='false') + ol + li.confirm-pri(ng-repeat='alternative in priorities') + p {{ alternative.description }} + + button.btn.btn-lg.btn-danger(type='button', ng-click='denyVote()') Avbryt + + button.btn.btn-lg.btn-success(type='button', ng-click='vote()') Bekreft + .access-code(ng-switch-when='false') + form.form-group.enter-code-form( + ng-submit='getActiveElection(accessCode)', + name='enterCodeForm' ) + .form-group.access-code(required) + label Kode + input.form-control( + type='number', + name='accessCode', + ng-model='accessCode', + placeholder='----' + ) + + button#submit.btn.btn-default.btn-lg.btn-success( + type='submit', + ng-disabled='accessCode.toString().length != 4' + ) Verifiser div(ng-switch-default) h2. diff --git a/app/views/partials/moderator/deactivateUsers.pug b/app/views/partials/moderator/deactivateUsers.pug index b8028942..047df253 100644 --- a/app/views/partials/moderator/deactivateUsers.pug +++ b/app/views/partials/moderator/deactivateUsers.pug @@ -1,3 +1,7 @@ .row .col-xs-12.col-sm-offset-3.col-sm-6.col-md-offset-4.col-md-4.text-center + h3 Deaktivere alle brukere slik at all alle må reaktivere sin bruker. + ol + li Fysisk: Scanne seg inn i lokale med adgangskort + li Digitalt: Skrive inn kode ved neste valg deactivate-users(deactivate-handler='deactivateNonAdminUsers()') Deaktiver brukere diff --git a/app/views/partials/moderator/generateUser.pug b/app/views/partials/moderator/generateUser.pug new file mode 100644 index 00000000..743937e2 --- /dev/null +++ b/app/views/partials/moderator/generateUser.pug @@ -0,0 +1,25 @@ +.row + .col-xs-12.col-sm-offset-3.col-sm-6.col-md-offset-4.col-md-4.text-center + form.form-group(ng-submit='generateUser(user)', name='generateUserForm') + .form-group + label Identifikator + input.form-control( + type='text', + name='identifier', + placeholder='Skriv inn identifikator', + ng-model='user.identifier' + ) + + .form-group + label Epost + input.form-control( + type='text', + name='email', + placeholder='Skriv inn epost', + ng-model='user.email' + ) + + button#submit.btn.btn-default.btn-lg( + type='submit', + ng-disabled='pending' + ) Generer bruker diff --git a/app/views/partials/moderator/manageRegister.pug b/app/views/partials/moderator/manageRegister.pug new file mode 100644 index 00000000..cceb8ad5 --- /dev/null +++ b/app/views/partials/moderator/manageRegister.pug @@ -0,0 +1,31 @@ +.row + .col-xs-12.col-sm-offset-3.col-sm-6.col-md-offset-4.col-md-4.text-center + h3 Genererte brukere + p Tabellen under viser en oversikt over alle genererte brukere. + | Brukere havner kun i denne oversikten hvis de er opprettet med + | "Generer bruker" fanen, eller direkte fra API'et. Brukere + | laget med "Registrer bruker" eller "QR" vil ikke vises under. +.center(style='margin-top: 50px') + hr + .usage-flex(style='margin: 0') + h4(style='text-align:right') Totalt {{ registers.length }} genererte brukere + label Search: + input(ng-model='searchText') + + table(style='width:100%; margin-top: 30px') + thead(style='border-bottom:2px solid black') + tr + th Identifikator + th Email + th(style='text-align:center') Fullført registrering + tbody(style='font-size:18px') + tr(ng-repeat='register in registers | filter:searchText') + th {{ register.identifier }} + th {{ register.email }} + th(style='text-align:center') {{ register.user ? "Nei" : "Ja" }} + th(style='text-align:right') + button.btn.btn-default( + ng-disabled='!register.user', + ng-click='deleteRegister(register._id)', + style='margin: 10px 0' + ) Slett diff --git a/app/views/partials/retrieveVote.pug b/app/views/partials/retrieveVote.pug index 170ea51e..559444d4 100644 --- a/app/views/partials/retrieveVote.pug +++ b/app/views/partials/retrieveVote.pug @@ -20,7 +20,13 @@ ) Hent avstemning .text-center.vote-result-feedback(ng-if='vote') - h3 Avstemning - p.vote-result-election {{ vote.alternative.election.title }} - h3 Valgt alternativ - p.vote-result-alternative {{ vote.alternative.description }} + h3 Din prioritering på: {{ vote.election.title }} + .confirmVotes(ng-switch='vote.priorities.length === 0') + .ballot + div(ng-switch-when='true') + h3 Blank stemme + i Din stemme vil fortsatt påvirke hvor mange stemmer som kreves for å vinne + div(ng-switch-when='false') + ol + li.confirm-pri(ng-repeat='alternative in vote.priorities') + p {{ alternative.description }} diff --git a/client/appRoutes.js b/client/appRoutes.js index a2cbe1c0..488dda89 100644 --- a/client/appRoutes.js +++ b/client/appRoutes.js @@ -31,6 +31,11 @@ module.exports = [ controller: 'createUserController', }) + .when('/moderator/generate_user', { + templateUrl: 'partials/moderator/generateUser', + controller: 'generateUserController', + }) + .when('/moderator/qr', { templateUrl: 'partials/moderator/qr', controller: 'createQRController', @@ -70,6 +75,11 @@ module.exports = [ controller: 'deactivateUsersController', }) + .when('/moderator/manage_register', { + templateUrl: 'partials/moderator/manageRegister', + controller: 'manageRegisterController', + }) + .otherwise({ templateUrl: 'partials/404', }); diff --git a/client/controllers/editElectionCtrl.js b/client/controllers/editElectionCtrl.js index f40b3e93..d680d75c 100644 --- a/client/controllers/editElectionCtrl.js +++ b/client/controllers/editElectionCtrl.js @@ -15,7 +15,7 @@ module.exports = [ ) { $scope.newAlternative = {}; $scope.election = null; - $scope.showCount = false; + $scope.showResult = false; var countInterval; function handleIntervalError(response) { @@ -64,7 +64,14 @@ module.exports = [ ); }; + function clearResults() { + $scope.showResult = false; + $scope.election.result = {}; + $scope.election.log = []; + } + $scope.toggleElection = function () { + clearResults(); if ($scope.election.active) { adminElectionService.deactivateElection().then( function (response) { @@ -88,21 +95,6 @@ module.exports = [ } }; - function getCount() { - adminElectionService.countVotes().then(function (response) { - $scope.election.alternatives.forEach(function (alternative) { - response.data.some(function (resultAlternative) { - if (resultAlternative.alternative === alternative._id) { - alternative.votes = resultAlternative.votes; - return true; - } - - return false; - }); - }); - }, handleIntervalError); - } - $scope.getPercentage = function (count) { if (count !== undefined) { var sum = 0; @@ -114,10 +106,19 @@ module.exports = [ } }; - $scope.toggleCount = function () { - $scope.showCount = !$scope.showCount; - if ($scope.showCount) { - getCount(); + $scope.toggleResult = function () { + $scope.showResult = !$scope.showResult; + if ($scope.showResult) { + adminElectionService.elect().then(function (response) { + $scope.election = { + ...$scope.election, + ...response.data, + status: + response.data.result.status == 'RESOLVED' ? 'success' : 'warning', + }; + }); + } else { + clearResults(); } }; @@ -138,5 +139,21 @@ module.exports = [ .path('/admin/create_election') .search({ election: JSON.stringify(election) }); }; + + $scope.copyToClipboard = function (text) { + const copyEl = document.createElement('textarea'); + copyEl.style.opacity = '0'; + copyEl.style.position = 'fixed'; + copyEl.textContent = text; + document.body.appendChild(copyEl); + copyEl.select(); + try { + document.execCommand('copy'); + $scope.copySuccess = true; + setTimeout(() => ($scope.copySuccess = null), 1000); + } finally { + document.body.removeChild(copyEl); + } + }; }, ]; diff --git a/client/controllers/electionCtrl.js b/client/controllers/electionCtrl.js index 4c8fddd5..7c6ebc93 100644 --- a/client/controllers/electionCtrl.js +++ b/client/controllers/electionCtrl.js @@ -16,40 +16,100 @@ module.exports = [ localStorageService ) { $scope.activeElection = null; - $scope.selectedAlternative = null; + $scope.electionExists = false; + $scope.priorities = []; + $scope.confirmVote = false; + $scope.correctCode = false; + $scope.accessCode = ''; /** * Tries to find an active election */ - function getActiveElection() { - return electionService.getActiveElection().then( + function getActiveElection(accessCode) { + return electionService.getActiveElection(accessCode).then( function (response) { + $scope.priorities = []; + $scope.electionExists = true; $scope.activeElection = response.data; + $scope.correctCode = true; }, function (response) { - alertService.addError(response.data.message); + if (response.status == '404') { + $scope.electionExists = false; + $scope.activeElection = null; + $scope.accessCode = ''; + $scope.correctCode = false; + } else if (response.status == '403') { + $scope.electionExists = true; + $scope.activeElection = null; + $scope.accessCode = ''; + $scope.correctCode = false; + } else { + alertService.addError(response.data.message); + } } ); } getActiveElection(); + $scope.getActiveElection = getActiveElection; socketIOService.listen('election', getActiveElection); + $scope.getPossibleAlternatives = function () { + return $scope.activeElection.alternatives.filter( + (e) => !$scope.priorities.includes(e) + ); + }; + + /** + * Update the priorities with a sortable event. + * @param {Object} evt + */ + $scope.updatePriority = function (evt) { + const { oldIndex, newIndex } = evt; + const alternative = $scope.priorities.splice(oldIndex, 1)[0]; + $scope.priorities.splice(newIndex, 0, alternative); + + // There might be a bug where angular does not re-render, so we force refresh + $scope.$apply(); + }; + /** - * Sets the given alternative to $scope + * Adds the given alternative to $scope.priorities * @param {Object} alternative */ $scope.selectAlternative = function (alternative) { - $scope.selectedAlternative = alternative; + $scope.priorities.push(alternative); + }; + + /** + * Removes the given alternative to $scope.priorities + * @param {string} id + */ + $scope.deselectAlternative = function (id) { + $scope.priorities = $scope.priorities.filter((a) => a._id !== id); + }; + + $scope.confirm = function () { + $scope.confirmVote = true; + }; + + $scope.denyVote = function () { + $scope.confirmVote = false; }; /** * Persists votes to the backend */ $scope.vote = function () { - voteService.vote($scope.selectedAlternative._id).then( + voteService.vote($scope.activeElection, $scope.priorities).then( function (response) { $window.scrollTo(0, 0); $scope.activeElection = null; + $scope.priorities = []; + $scope.electionExists = false; + $scope.confirmVote = false; + $scope.correctCode = false; + $scope.accessCode = ''; alertService.addSuccess('Takk for din stemme!'); localStorageService.set('voteHash', response.data.hash); getActiveElection(); @@ -86,7 +146,7 @@ module.exports = [ * @return {Boolean} */ $scope.isChosen = function (alternative) { - return alternative === $scope.selectedAlternative; + return $scope.priorities.includes(alternative); }; }, ]; diff --git a/client/controllers/generateUserCtrl.js b/client/controllers/generateUserCtrl.js new file mode 100644 index 00000000..aec6efc9 --- /dev/null +++ b/client/controllers/generateUserCtrl.js @@ -0,0 +1,32 @@ +module.exports = [ + '$scope', + 'userService', + 'alertService', + function ($scope, userService, alertService) { + $scope.generateUser = function (user) { + $scope.user = {}; + $scope.pending = true; + userService.generateUser(user).then( + function (response) { + alertService.addSuccess( + `Bruker ${response.data.user} ble ${response.data.status}!` + ); + $scope.user = {}; + $scope.pending = false; + }, + function (response) { + $scope.pending = false; + switch (response.status) { + case 409: + alertService.addError( + 'Denne idenfikatoren har allerede fått en bruker.' + ); + break; + default: + alertService.addError(); + } + } + ); + }; + }, +]; diff --git a/client/controllers/index.js b/client/controllers/index.js index 9ef1791a..973b3778 100644 --- a/client/controllers/index.js +++ b/client/controllers/index.js @@ -3,6 +3,7 @@ angular .controller('changeCardController', require('./changeCardCtrl')) .controller('createElectionController', require('./createElectionCtrl')) .controller('createUserController', require('./createUserCtrl')) + .controller('generateUserController', require('./generateUserCtrl')) .controller('createQRController', require('./createQRCtrl')) .controller('deactivateUsersController', require('./deactivateUsersCtrl')) .controller('editElectionController', require('./editElectionCtrl')) @@ -11,4 +12,5 @@ angular .controller('logoutController', require('./logoutCtrl')) .controller('retrieveVoteController', require('./retrieveVoteCtrl')) .controller('showQRController', require('./showQRCtrl')) - .controller('toggleUserController', require('./toggleUserCtrl')); + .controller('toggleUserController', require('./toggleUserCtrl')) + .controller('manageRegisterController', require('./manageRegisterCtrl')); diff --git a/client/controllers/manageRegisterCtrl.js b/client/controllers/manageRegisterCtrl.js new file mode 100644 index 00000000..481230a5 --- /dev/null +++ b/client/controllers/manageRegisterCtrl.js @@ -0,0 +1,34 @@ +module.exports = [ + '$scope', + '$route', + 'registerService', + 'alertService', + function ($scope, $route, registerService, alertService) { + $scope.registers = []; + function getRegisterEntries() { + registerService.getRegisterEntries().then( + function (response) { + $scope.registers = response.data; + }, + function (response) { + alertService.addError(response.message); + } + ); + } + getRegisterEntries(); + + $scope.deleteRegister = function (register) { + if (confirm('Er du sikker på at du vil slette denne brukeren?')) { + registerService.deleteRegisterEntry(register).then( + function (response) { + alertService.addSuccess(response.data.message); + $route.reload(); + }, + function (response) { + alertService.addError(response.message); + } + ); + } + }; + }, +]; diff --git a/client/directives/confirmVoteDirective.js b/client/directives/confirmVoteDirective.js deleted file mode 100644 index 2bdfc941..00000000 --- a/client/directives/confirmVoteDirective.js +++ /dev/null @@ -1,36 +0,0 @@ -module.exports = function () { - return { - restrict: 'E', - replace: true, - scope: { - selectedAlternative: '=', - voteHandler: '&', - }, - template: - '' + - '', - - link: function (scope, elem, attrs) { - var clicked = false; - - scope.$watch('selectedAlternative', function (newValue) { - if (newValue) { - scope.buttonText = 'Avgi stemme'; - clicked = false; - } - }); - - scope.click = function () { - if (!clicked) { - clicked = true; - scope.buttonText = 'Er du sikker?'; - } else { - scope.voteHandler(); - } - }; - }, - }; -}; diff --git a/client/directives/index.js b/client/directives/index.js index 60d8d0b1..da77709d 100644 --- a/client/directives/index.js +++ b/client/directives/index.js @@ -1,5 +1,5 @@ angular .module('voteApp') .directive('deactivateUsers', require('./confirmDeactivateDirective')) - .directive('confirmVote', require('./confirmVoteDirective')) - .directive('matchPassword', require('./passwordDirective')); + .directive('matchPassword', require('./passwordDirective')) + .directive('sortable', require('./sortableDirective')); diff --git a/client/directives/sortableDirective.js b/client/directives/sortableDirective.js new file mode 100644 index 00000000..d8ec7b5b --- /dev/null +++ b/client/directives/sortableDirective.js @@ -0,0 +1,47 @@ +const Sortable = require('sortablejs').Sortable; + +module.exports = function () { + return { + restrict: 'A', + scope: { + sortableList: '=', + sortableAnimation: '=', + sortableOnUpdate: '=', + sortableDelay: '=', + sortableHandle: '@sortableHandle', + }, + link: function (scope, elem) { + let img; + Sortable.create(elem[0], { + delay: scope.sortableDelay, + delayOnTouchOnly: true, + animation: scope.sortableAnimation, + handle: scope.sortableHandle, + setData: function (dataTransfer, el) { + img = el.cloneNode(true); + img.style.visibility = 'hidden'; + img.style.top = '0'; + img.style.left = '0'; + img.style.position = 'absolute'; + + document.body.appendChild(img); + + dataTransfer.setDragImage(img, 0, 0); + }, + onEnd: function () { + img && img.parentNode && img.parentNode.removeChild(img); + }, + onUpdate: scope.sortableOnUpdate, + onChoose: function () { + setTimeout( + () => window.navigator.vibrate && window.navigator.vibrate(100), + 200 + ); + }, + onMove: function () { + window.navigator.vibrate && window.navigator.vibrate(50); + }, + }); + }, + }; +}; diff --git a/client/login.js b/client/login.js index 7486d129..8d4ba6be 100644 --- a/client/login.js +++ b/client/login.js @@ -4,26 +4,30 @@ import QrScannerWorkerPath from '!!file-loader!qr-scanner/qr-scanner-worker.min. QrScanner.WORKER_PATH = QrScannerWorkerPath; if ('addEventListener' in document) { document.addEventListener('DOMContentLoaded', function () { + // Get the token string form the url, on the format username:password:code const getTokenFromUrl = (url) => { const urlParams = new URLSearchParams(getLocation(url).search); return urlParams.get('token'); }; + + // Helper function const getLocation = function (href) { var l = document.createElement('a'); l.href = href; return l; }; - const doTokenThing = (url) => { + + // Parse and insert values from token + const parseAndUseToken = (url) => { try { const [u, p, code] = getTokenFromUrl(url).split(':'); + document.querySelector('[name=username]').value = u; + document.querySelector('[name=username]').style.textAlign = 'center'; + document.querySelector('[name=password]').value = p; document.querySelector('[name=password]').type = 'text'; + document.querySelector('[name=password]').style.textAlign = 'center'; - document.querySelector('#alertInfo').setAttribute('class', ''); - - document.querySelector('[name=usingToken]').value = true; - - document.querySelector('[name=username]').value = u; document .querySelector('[name=username]') .setAttribute('readonly', 'readonly'); @@ -32,41 +36,48 @@ if ('addEventListener' in document) { .querySelector('[name=password]') .setAttribute('readonly', 'readonly'); - document.querySelector('[type=submit]').style.display = 'none'; - document.querySelector('#testing').style.display = 'none'; + // If the user gets token from mail the code will be "" + if (code) { + document.querySelector('#alertInfo').setAttribute('class', ''); + document.querySelector('[name=usingToken]').value = true; + document.querySelector('[type=submit]').style.display = 'none'; + document.querySelector('#testing').style.display = 'none'; - document - .querySelector('[id=confirmScreenshot]') - .setAttribute('class', ''); - document.querySelector('[id=confirmScreenshot]').onclick = function ( - e - ) { - e.target.setAttribute('class', 'hidden'); - document.querySelector('[type=submit]').style.display = ''; - }; + document + .querySelector('[id=confirmScreenshot]') + .setAttribute('class', ''); + document.querySelector('[id=confirmScreenshot]').onclick = function ( + e + ) { + e.target.setAttribute('class', 'hidden'); + document.querySelector('[type=submit]').style.display = ''; + }; - fetch('/api/qr/open/?code=' + code); - QRCode.toDataURL(url, { type: 'image/png', width: 300 }, function ( - err, - url - ) { - document.querySelector('[id=qrImg]').setAttribute('src', url); - }); + fetch('/api/qr/open/?code=' + code); + QRCode.toDataURL(url, { type: 'image/png', width: 300 }, function ( + err, + url + ) { + document.querySelector('[id=qrImg]').setAttribute('src', url); + }); + } } catch (e) { alert('Det skjedde en feil. Prøv på nytt'); /* eslint no-console: 0 */ console.warn('Unable to decode token: ', e); } }; + + // Get token const token = getTokenFromUrl(window.location.href); if (token) { - doTokenThing(window.location.href); + parseAndUseToken(window.location.href); } else { QrScanner.hasCamera(); const qrScanner = new QrScanner( document.getElementById('testing'), (result) => { - doTokenThing(result); + parseAndUseToken(result); } ); qrScanner.start(); diff --git a/client/services/adminElectionService.js b/client/services/adminElectionService.js index c0b6e70c..788ac432 100644 --- a/client/services/adminElectionService.js +++ b/client/services/adminElectionService.js @@ -21,7 +21,7 @@ module.exports = [ return $http.post('/api/election/' + $routeParams.param + '/deactivate'); }; - this.countVotes = function () { + this.elect = function () { return $http.get('/api/election/' + $routeParams.param + '/votes'); }; diff --git a/client/services/electionService.js b/client/services/electionService.js index 28bc8ec0..f71a913f 100644 --- a/client/services/electionService.js +++ b/client/services/electionService.js @@ -1,9 +1,8 @@ module.exports = [ '$http', - '$routeParams', - function ($http, $routeParams) { - this.getActiveElection = function () { - return $http.get('/api/election/active'); + function ($http) { + this.getActiveElection = function (accessCode) { + return $http.get(`/api/election/active?accessCode=${accessCode}`); }; }, ]; diff --git a/client/services/index.js b/client/services/index.js index 5cea6105..f0ce19ad 100644 --- a/client/services/index.js +++ b/client/services/index.js @@ -7,4 +7,5 @@ angular .factory('voteService', require('./voteService')) .service('adminElectionService', require('./adminElectionService')) .service('electionService', require('./electionService')) - .service('userService', require('./userService')); + .service('userService', require('./userService')) + .service('registerService', require('./registerService')); diff --git a/client/services/registerService.js b/client/services/registerService.js new file mode 100644 index 00000000..38e72891 --- /dev/null +++ b/client/services/registerService.js @@ -0,0 +1,12 @@ +module.exports = [ + '$http', + function ($http) { + this.getRegisterEntries = function () { + return $http.get('/api/register'); + }; + + this.deleteRegisterEntry = function (register) { + return $http.delete(`/api/register/${register}`); + }; + }, +]; diff --git a/client/services/userService.js b/client/services/userService.js index c1bb75d9..222546d8 100644 --- a/client/services/userService.js +++ b/client/services/userService.js @@ -9,6 +9,10 @@ module.exports = [ return $http.post('/api/user', user); }; + this.generateUser = function (user) { + return $http.post('/api/user/generate', user); + }; + this.changeCard = function (user) { return $http.put('/api/user/' + user.username + '/change_card', user); }; diff --git a/client/services/voteService.js b/client/services/voteService.js index f745a172..331407c2 100644 --- a/client/services/voteService.js +++ b/client/services/voteService.js @@ -2,8 +2,8 @@ module.exports = [ '$http', function ($http) { return { - vote: function (alternativeId) { - return $http.post('/api/vote', { alternativeId: alternativeId }); + vote: function (election, priorities) { + return $http.post('/api/vote', { election, priorities }); }, retrieve: function (voteHash) { return $http.get('/api/vote', { headers: { 'Vote-Hash': voteHash } }); diff --git a/client/styles/admin.styl b/client/styles/admin.styl index 81cd178a..338d1d3d 100644 --- a/client/styles/admin.styl +++ b/client/styles/admin.styl @@ -10,6 +10,10 @@ form cursor pointer color $abakus-dark + input[type='checkbox'] + transform scale(1.5) + margin-left 10px + .user-status color $abakus-light @@ -52,6 +56,19 @@ form cursor pointer color $abakus-dark +span.access-code + font-weight 800 + font-size 30px + padding-left 15px + +.copy-icon + font-size initial + vertical-align middle + cursor pointer + + &:hover + color $abakus-light + .alternatives.admin padding-top 10px margin-bottom 0 @@ -63,6 +80,7 @@ form &:hover cursor default background-color alpha($alternative-background, 0.15) + box-shadow none span position absolute @@ -92,6 +110,10 @@ form.add-alternative .toggle-show margin-left 0 +.large + font-size 25px + text-align center + .big font-size 50px text-align center @@ -124,3 +146,18 @@ form.add-alternative animation-duration: 4s animation-iteration-count: infinite animation-direction: alternate + +.log + li + margin-bottom 10px + border-radius 2px + border 1px solid transparent + background-color alpha(#000080, 0.15) + p + margin 0 + font-size 16px + line-height 20px + h5 + margin 5px + text-decoration underline + font-weight 1000 diff --git a/client/styles/election.styl b/client/styles/election.styl index 7b019f26..46dcdc92 100644 --- a/client/styles/election.styl +++ b/client/styles/election.styl @@ -1,13 +1,21 @@ -.election-info - margin-bottom 40px - - h2 +.election-info h2 text-transform uppercase +.helptext + height 70px + +.access-code + input + width 150px + text-align center + font-size 25px + margin auto + font-family monospace + .alternatives - margin-bottom 30px + margin-bottom 20px + padding-top 20px border-top 1px solid rgba(177, 181, 188, 0.3) - padding-top 40px h3 margin-bottom 25px @@ -26,39 +34,140 @@ background-color #f9f9f9 color $abakus-dark + ul + min-height 60px + text-align right + + .numbered + min-height initial + ul li margin-bottom 10px - line-height 60px + height 60px border-radius 2px - border 1px solid transparent background-color alpha($alternative-background, 0.15) - transition all 0.5s + display flex + justify-content space-between + margin-left auto &:hover + &.sortable-chosen cursor pointer - border 1px solid alpha($alternative-background, 0.15) background-color alpha($alternative-background, 0.05) + box-shadow 1px 2px 5px #ccc, 0px -2px 5px #ccc + + .content + line-height 60px + text-align center + flex-grow 1 + + &.selected + background-color alpha($alternative-background, 0.4) + + p + color white p color $abakus-light opacity 1 + margin 0 + color darken($font-gray, 20%) + text-transform uppercase + font-weight 200 + transition color 0.3s + vertical-align middle + display inline-block + line-height 24px - &.selected - background-color alpha($alternative-background, 0.4) + .icon + display flex + align-items center + justify-content center + border-radius 2px + height 100% + width 15% - p - color white - - p - color darken($font-gray, 20%) - text-transform uppercase - margin-bottom 0 - font-weight 200 - transition all 0.3s + &:hover + box-shadow: 0 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); + + &.remove + color $abakus-dark + background-color alpha($abakus-dark, 0.4) + &.add + color green + background-color alpha(green, 0.4) + + +.numbered + counter-reset item + text-align right + + li + width 95% + counter-increment item + position relative + display inline-block + + &:before + position absolute + top 0 + left -25px + margin-right 10px + content counter(item) + font-size 35px + color $abakus-dark + display inline-block + text-align center + + .content + cursor move + + .content p + user-select: none; + -webkit-touch-callout: none; + width 90% + + .drag + position absolute + height 100% + line-height 60px + width 10% + height 100% vertical-align middle +.btn + margin 20px + +.ballot + border 1px solid alpha($alternative-background, 0.3) + background-color alpha($alternative-background, 0.05) + border-radius 8px + + ol + margin 0 + + .confirm-pri + text-align left + line-height 40px + + p + text-transform uppercase + vertical-align middle + margin 0 + @media (max-width 1000px) .alternatives button.btn min-width 60% + + ul + .sortable-drag + display none + + li + .close + opacity .5 + + &:hover + background-color inherit diff --git a/client/styles/main.styl b/client/styles/main.styl index 7a888d59..bbef2f41 100644 --- a/client/styles/main.styl +++ b/client/styles/main.styl @@ -1,5 +1,6 @@ @import url('https://fonts.googleapis.com/css?family=Raleway:300,200,100') +@import url('https://fonts.googleapis.com/css2?family=Cutive+Mono&display=swap'); $font-gray = #666c77 $alternative-background = darken($font-gray, 20%) @@ -99,6 +100,10 @@ hr margin 0 auto font-size 21px +.mono + font-family 'Cutive Mono', monospace + font-size 16px + label font-weight 300 font-size 20px @@ -142,6 +147,12 @@ label input width 100% + .header + padding-top 0 + + footer + padding-top 20px + .usage-flex display flex justify-content space-between @@ -150,6 +161,48 @@ label a align-self flex-end +.th-right + text-align right + width 50% + +.th-left + text-align left + width 50% + + +.cs-tooltip + position relative + display inline-block + border-bottom 1px dotted black + + .cs-tooltiptext + visibility hidden + width 120px + bottom 130% + left 50% + margin-left -60px + background-color black + color #fff + text-align center + padding 5px 0 + border-radius 6px + position absolute + z-index 1 + + &::after + content " " + position absolute + top 100% + left 50% + margin-left -5px + border-width 5px + border-style solid + border-color black transparent transparent transparent + + &:hover + .cs-tooltiptext + visibility visible + @import 'election' @import 'admin' diff --git a/deployment/README.md b/deployment/README.md index 08ee9c39..7633be34 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -1,20 +1,27 @@ +# Deployment + +To deploy VOTE, any method supporting docker containers can be used. Updated docker images are +available on [dockerhub](https://hub.docker.com/r/abakus/vote). This image can be deployed with +configuration of environment variables as in the docker-compose example below. Make sure a redis and +MongoDB instance is available to the container as well. + ## Example deployment using docker-compose This is an example of a deployment of `vote` using docker-compose. -## Start service +### Start service ```bash $ docker-compose up -d ``` -## Stop the service, and delete all the data +### Stop the service, and delete all the data ```bash $ docker-compose down ``` -## Create users +### Create users The vote-CLI allows you to create **admin**, **moderator** and **normal** users. All users are created using the `create-user` command. The command takes two command line arguments, **username** and **card-key**. Both need to be unique and **username** is required to be at least 5 characters. @@ -35,10 +42,16 @@ $ Created user > The vote-server is now running on `http://localhost:3000`, so visit that url and login using your account. -## Exposing to the interwebz +### Exposing to the interwebz The vote service can be exposed to the web using a reverse-proxy like nginx, caddy or traefik. The only port that needs forwarding is port `3000`. Using https is also a must! :100: +## Registering users + +There are three ways that can be used to generate users. The first two, the _input form_ and _QR generator_, require the +user to be present at the computer logged into as a moderator. The last option, _using email_, can +be used with a digital election where the users are not present at the election itself. + ### Register new users using input form New users can be created in the `Registrer bruker` tab by scanning a card and filling out the form. @@ -47,6 +60,14 @@ New users can be created in the `Registrer bruker` tab by scanning a card and fi New users can be created in the `QR` tab. By scanning a card a new user is automatically created with a random username and password. The data is encoded into the QR-code, so when a user scans the code they are automatically logged in. They are also promoted to save the username and password on their phone, in case they get logged out or want to login using another device. -// TODO allow users to customise the path of the vote-instance. Currently defaults to https://vote.abakus.no +### Register users using email + +New users can be created in the `generer bruker` tab. By inputting the user's email and a username +(this can be whatever, but it is here to validate uniqueness of users). The user will be sent an +email with login credentials. + +> NOTE: this requires email to be set up! -// TODO add k8s manifests +To set up email, use **either** of the authentication methods to connect to SMTP listed in the main +README. Use `GOOGLE_AUTH` for a service account connected to a google cloud user, or `SMTP_URL` to +connect to any smtp server with a username and password. diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index c2ec1fa6..5eddbc7f 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -2,9 +2,9 @@ version: '2' services: mongo: - image: mongo:3.6 + image: mongo:4.4 redis: - image: redis:latest + image: redis:6.0 vote: image: abakus/vote:latest environment: @@ -12,6 +12,10 @@ services: MONGO_URL: 'mongodb://mongo:27017/vote' REDIS_URL: 'redis' COOKIE_SECRET: 'long-secret-here-is-important' - LOGO_SRC: 'https://raw.githubusercontent.com/webkom/lego/master/assets/abakus_webkom.png' + ICON_SRC: 'https://raw.githubusercontent.com/webkom/lego/master/assets/abakus_webkom.png' + FROM: 'YourCompany' + FROM_MAIL: "noreply@example.com" + SMTP_URL: 'smtps://username:password@smtp.example.com' + FRONTEND_URL: 'https://vote.example.com' ports: - '127.0.0.1:3000:3000' diff --git a/deployment/sync/main.go b/deployment/sync/main.go index e7613477..42dd8093 100644 --- a/deployment/sync/main.go +++ b/deployment/sync/main.go @@ -193,11 +193,11 @@ func runLegoToVoteSync(interrupt chan os.Signal, url *url.URL, jwt string) error } voteFormData := struct { - Email string `json:"email"` - LegoUser string `json:"legoUser"` + Email string `json:"email"` + Identifier string `json:"identifier"` }{ - Email: legoUserData.Email, - LegoUser: action.Payload.User.Username, + Email: legoUserData.Email, + Identifier: action.Payload.User.Username, } out, err := json.Marshal(voteFormData) diff --git a/docker-compose.yml b/docker-compose.yml index a1b4b556..4d6fb195 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,11 +2,10 @@ version: '2' services: mongo: - image: mongo:3.6 + image: mongo:4.4 ports: - '127.0.0.1:27017:27017' redis: - image: redis:latest + image: redis:6.0 ports: - '127.0.0.1:6379:6379' - diff --git a/env.js b/env.js index ebd51e5b..dbce9c87 100644 --- a/env.js +++ b/env.js @@ -1,6 +1,6 @@ module.exports = { // URL/source to the logo on all pages - LOGO_SRC: process.env.LOGO_SRC || '/static/images/Abakule.jpg', + ICON_SRC: process.env.ICON_SRC || '/static/images/Abakule.jpg', // Node environment. 'development' or 'production' NODE_ENV: process.env.NODE_ENV || 'development', // This cannot be empty when running in production @@ -8,8 +8,14 @@ module.exports = { PORT: process.env.PORT || 3000, // Host used when binding. Use 0.0.0.0 to bind all interfaces HOST: process.env.HOST || 'localhost', - // DSN url for reporting errors to sentry - RAVEN_DSN: process.env.RAVEN_DSN, MONGO_URL: process.env.MONGO_URL || 'mongodb://localhost:27017/vote', REDIS_URL: process.env.REDIS_URL || 'localhost', + FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:3000', + + // Mail settings + FROM: process.env.FROM || 'Abakus', + FROM_MAIL: process.env.FROM_MAIL || 'admin@abakus.no', + // Use one of the below + GOOGLE_AUTH: process.env.GOOGLE_AUTH, + SMTP_URL: process.env.SMTP_URL, }; diff --git a/features/admin.feature b/features/admin.feature index 406db96f..ce1d8b23 100644 --- a/features/admin.feature +++ b/features/admin.feature @@ -8,19 +8,44 @@ Feature: Admin When I am on page "/admin" Then I see a list of elections - Scenario: Create election + Scenario: Create basic election Given There is an inactive election And I am on page "/admin/create_election" When I create an election Then The election should exist + Scenario: Create invalid election + Given There is an inactive election + And I am on page "/admin/create_election" + When I fill in "title" with "test election" + And I fill in "seats" with "2" + And I fill in "alternative0" with "A" + Then Button "Submit" should be disabled + + Scenario: Create election with more seats + Given There is an inactive election + And I am on page "/admin/create_election" + When I fill in "title" with "test election" + And I fill in "seats" with "2" + And I fill in "alternative0" with "A" + And I click anchor "new-alternative" + And I fill in "alternative1" with "B" + Then Button "Submit" should not be disabled + When I click "Submit" + Then I see alert "Avstemning lagret" + Scenario: Count votes Given There is an active election And The election has votes And The election is deactivated And I am on the edit election page - When I click "Vis resultat" + When I click "Kalkuler resultat" Then I should see votes + And I should see 1 in "election.voteCount" + And I should see 0 in "election.blankVoteCount" + And I should see 1 in "election.seats" + And There should be 1 winner + And I should see "test alternative" as winner 1 Scenario: Count votes for active elections Given There is an active election diff --git a/features/election.feature b/features/election.feature index 1b7c9c69..0494d9a9 100644 --- a/features/election.feature +++ b/features/election.feature @@ -16,19 +16,41 @@ Feature: Election When I go to page "/" Then I see "Ingen aktive avstemninger." - Scenario: Voting + Scenario: Voting on one alternative Given There is an active election When I vote on an election Then I see alert "Takk for din stemme!" + Scenario: Prioritizing alternatives + Given There is an active election + When I select "another test alternative" + When I select "test alternative" + Then I see "another test alternative" as priority 1 + And I see "test alternative" as priority 2 + When I submit the vote + Then I see "test alternative" as priority 2 on the confirmation ballot + And I see "another test alternative" as priority 1 on the confirmation ballot + When I confirm the vote + Then I see alert "Takk for din stemme!" + + Scenario: Voting blank + Given There is an active election + When I submit the vote + Then I see "Blank stemme" in ".ballot h3" + When I confirm the vote + Then I see alert "Takk for din stemme!" + Given I am on page "/retrieve" + When I submit the form + Then I see "Blank stemme" in ".ballot h3" + Scenario: Retrieve vote from localStorage Given There is an active election And I have voted on the election And I am on page "/retrieve" Then I see my hash in "voteHash" When I submit the form - Then I see "activeElection1" in "vote-result-election" - And I see "test alternative" in "vote-result-alternative" + Then I see "Din prioritering på: activeElection1" in ".vote-result-feedback h3" + And I see "test alternative" as priority 1 on the receipt Scenario: Retrieve vote with invalid hash Given There is an active election diff --git a/features/step_definitions/adminSteps.js b/features/step_definitions/adminSteps.js index 4541bde2..87348382 100644 --- a/features/step_definitions/adminSteps.js +++ b/features/step_definitions/adminSteps.js @@ -26,7 +26,9 @@ module.exports = function () { const election = alternatives.first(); Bluebird.all([ - expect(election.getText()).to.eventually.equal(this.election.title), + expect(election.element(by.css('span')).getText()).to.eventually.equal( + this.election.title + ), expect(alternatives.count()).to.eventually.equal(1), ]); }); @@ -65,22 +67,48 @@ module.exports = function () { }) ); - this.Given(/^The election has votes$/, function () { - this.alternative.addVote(this.user); + this.Given(/^The election has votes$/, async function () { + await this.election.addVote(this.user, [this.alternatives[0]]); }); this.Given(/^I am on the edit election page$/, function () { browser.get(`/admin/election/${this.election.id}/edit`); }); - this.Then(/^I should see votes$/, () => { + this.Then(/^I should see votes$/, function () { const alternatives = element.all( - by.repeater('alternative in election.alternatives') + by.repeater('(key, value) in elem.counts') ); const alternative = alternatives.first(); - const span = alternative.element(by.tagName('span')); - return expect(span.getText()).to.eventually.equal('1 - 100 %'); + return Bluebird.all([ + expect(alternative.getText()).to.eventually.equal( + `${this.alternatives[0].description} with 1 votes` + ), + expect(alternatives.get(1).getText()).to.eventually.equal( + `${this.alternatives[1].description} with 0 votes` + ), + expect(alternatives.get(2).getText()).to.eventually.equal( + `${this.alternatives[2].description} with 0 votes` + ), + ]); + }); + + this.Then(/^There should be (\d+) winners?$/, (count) => { + const winners = element.all( + by.repeater('winner in election.result.winners') + ); + return expect(winners.count()).to.eventually.equal(Number(count)); + }); + + this.Then(/^I should see "([^"]*)" as winner (\d+)$/, (winner, number) => { + const winners = element.all( + by.repeater('winner in election.result.winners') + ); + + return expect( + winners.get(Number(number) - 1).getText() + ).to.eventually.equal(`Vinner ${number}: ${winner}`); }); this.When(/^I enter a new alternative "([^"]*)"$/, (alternative) => { @@ -101,7 +129,7 @@ module.exports = function () { }); this.Then(/^I should see ([\d]+) in "([^"]*)"$/, (count, binding) => { - const countElement = element(by.binding(binding)); + const countElement = element.all(by.binding(binding)).first(); return expect(countElement.getText()).to.eventually.equal(String(count)); }); }; diff --git a/features/step_definitions/electionSteps.js b/features/step_definitions/electionSteps.js index e1f0367d..479d5867 100644 --- a/features/step_definitions/electionSteps.js +++ b/features/step_definitions/electionSteps.js @@ -6,6 +6,14 @@ const expect = chai.expect; chai.use(chaiAsPromised); +by.addLocator( + 'sortableListItems', + (sortableList, opt_parentElement, opt_rootSelector) => { + var using = opt_parentElement || document; + return using.querySelectorAll(`[sortable-list="${sortableList}"] li`); + } +); + module.exports = function () { this.Given(/^There is an (in)?active election$/, function (arg) { const active = arg !== 'in'; @@ -27,7 +35,7 @@ module.exports = function () { const title = element(by.binding('activeElection.title')); const description = element(by.binding('activeElection.description')); const alternatives = element.all( - by.repeater('alternative in activeElection.alternatives') + by.repeater('alternative in getPossibleAlternatives()') ); return Bluebird.all([ @@ -37,35 +45,107 @@ module.exports = function () { expect(description.getText()).to.eventually.equal( this.election.description ), - expect(alternatives.count()).to.eventually.equal(1), + expect(alternatives.count()).to.eventually.equal( + this.alternatives.length + ), expect(alternatives.first().getText()).to.eventually.contain( - this.alternative.description.toUpperCase() + this.alternatives[0].description.toUpperCase() ), ]); }); + this.When(/^I select "([^"]*)"$/, function (alternative) { + const alternatives = element.all( + by.repeater('alternative in getPossibleAlternatives()') + ); + const wantedAlternative = alternatives + .filter((a) => + a.getText().then((text) => text === alternative.toUpperCase()) + ) + .first(); + + wantedAlternative.click(); + }); + + function confirmVote(confirmation) { + const denyButton = element(by.buttonText('Avbryt')); + const confirmButton = element(by.buttonText('Bekreft')); + + confirmation ? confirmButton.click() : denyButton.click(); + } + + this.When(/^I (deny|confirm) the vote$/, (buttonText) => { + confirmVote(buttonText == 'confirm'); + }); + + this.When(/^I submit the vote$/, () => { + element(by.css('button')).click(); + }); + function vote() { const alternatives = element.all( - by.repeater('alternative in activeElection.alternatives') + by.repeater('alternative in getPossibleAlternatives()') ); const alternative = alternatives.first(); const button = element(by.css('button')); alternative.click(); button.click(); - button.click(); } - this.Given(/^I have voted on the election$/, vote); + this.Given(/^I have voted on the election$/, function () { + vote(); + confirmVote(true); + }); - this.When(/^I vote on an election$/, vote); + this.When(/^I vote on an election$/, function () { + vote(); + confirmVote(true); + }); this.Then(/^I see my hash in "([^"]*)"$/, function (name) { const input = element(by.name(name)); return Vote.findOne({ - alternative: this.alternative.id, + priorities: { + $all: [this.alternatives[0]], + }, }).then((foundVote) => expect(input.getAttribute('value')).to.eventually.equal(foundVote.hash) ); }); + + this.Then(/^I see "([^"]*)" as priority (\d+)$/, function ( + alternative, + position + ) { + const priorities = element.all(by.sortableListItems('priorities')); + + return expect( + priorities.get(Number(position) - 1).getText() + ).to.eventually.contain(alternative.toUpperCase()); + }); + + this.Then( + /^I see "([^"]*)" as priority (\d+) on the confirmation ballot$/, + function (alternative, position) { + const priorities = element.all(by.repeater('alternative in priorities')); + + return expect( + priorities.get(Number(position) - 1).getText() + ).to.eventually.contain(alternative.toUpperCase()); + } + ); + + this.Then(/^I see "([^"]*)" as priority (\d+) on the receipt$/, function ( + alternative, + position + ) { + const priorities = element.all( + by.repeater('alternative in vote.priorities') + ); + + return expect( + priorities.get(Number(position) - 1).getText() + ).to.eventually.contain(alternative.toUpperCase()); + }); }; diff --git a/features/step_definitions/webSteps.js b/features/step_definitions/webSteps.js index a2f2d179..b2e32479 100644 --- a/features/step_definitions/webSteps.js +++ b/features/step_definitions/webSteps.js @@ -59,6 +59,11 @@ module.exports = function () { button.click(); }); + this.When(/^I click anchor "([^"]*)"$/, (classname) => { + const anchor = element(by.className(classname)); + anchor.click(); + }); + this.Then(/^I should find "([^"]*)"$/, (selector) => expect(element(by.css(selector)).isPresent()).to.eventually.equal(true) ); @@ -80,7 +85,7 @@ module.exports = function () { }); this.Then(/^I see "([^"]*)" in "([^"]*)"$/, (value, className) => { - const field = element(by.className(className)); + const field = element(by.css(className)); expect(field.getText()).to.eventually.equal(value); }); @@ -88,4 +93,12 @@ module.exports = function () { const found = element.all(by.css(css)); expect(found.count()).to.eventually.equal(Number(count)); }); + + this.Then( + /^Button "([^"]*)" should( not)? be disabled$/, + (buttonText, not) => { + const button = element(by.buttonText(buttonText)); + expect(button.isEnabled()).to.eventually.equal(!!not); + } + ); }; diff --git a/features/support/hooks.js b/features/support/hooks.js index de2fdcb8..b56cbdf1 100644 --- a/features/support/hooks.js +++ b/features/support/hooks.js @@ -17,25 +17,31 @@ module.exports = function () { const testAlternative = { description: 'test alternative', }; + const testAlternative2 = { + description: 'another test alternative', + }; + const testAlternative3 = { + description: 'last test alternative', + }; + + const alternatives = [testAlternative, testAlternative2, testAlternative3]; + + this.Before(async function () { + await clearCollections(); + const election = await new Election(activeElectionData); + this.election = election; + this.alternatives = await Promise.all( + alternatives.map((alternative) => new Alternative(alternative)) + ); + + for (let i = 0; i < alternatives.length; i++) { + await election.addAlternative(this.alternatives[i]); + } - this.Before(function () { - return clearCollections() - .bind(this) - .then(() => { - const election = new Election(activeElectionData); - return election.save(); - }) - .then(function (election) { - this.election = election; - testAlternative.election = election; - this.alternative = new Alternative(testAlternative); - return election.addAlternative(this.alternative); - }) - .then(() => createUsers()) - .spread(function (user, adminUser) { - this.user = user; - this.adminUser = adminUser; - }); + await createUsers().spread((user, adminUser) => { + this.user = user; + this.adminUser = adminUser; + }); }); this.registerHandler('BeforeFeatures', (event, callback) => { diff --git a/package.json b/package.json index a0336667..a47506a7 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,10 @@ "db:seed": "node --no-deprecation test/scripts/db_seed.js", "lint": "yarn lint:eslint && yarn lint:prettier && yarn lint:yaml", "lint:eslint": "eslint . --ignore-path .gitignore", - "lint:prettier": "prettier '**/*.{js,pug}' --list-different --ignore-path .gitignore", + "lint:prettier": "prettier '**/*.{js,ts,pug}' --list-different", "lint:yaml": "yarn yamllint .drone.yml docker-compose.yml usage.yml deployment/docker-compose.yml", - "prettier": "prettier '**/*.{js,pug}' --write", - "mocha": "NODE_ENV=test MONGO_URL=${MONGO_URL:-'mongodb://localhost:27017/vote-test'} nyc mocha test/**/*.test.js --exit --timeout 3000", + "prettier": "prettier '**/*.{js,ts,pug}' --write", + "mocha": "NODE_ENV=test MONGO_URL=${MONGO_URL:-'mongodb://localhost:27017/vote-test'} nyc mocha test/**/*.test.js --exit --timeout 30000", "coverage": "nyc report --reporter=text-lcov | coveralls", "protractor": "webdriver-manager update --standalone false --versions.chrome 86.0.4240.22 && NODE_ENV=protractor MONGO_URL=${MONGO_URL:-'mongodb://localhost:27017/vote-test'} protractor ./features/protractor-conf.js", "postinstall": "yarn build" @@ -30,6 +30,7 @@ }, "license": "MIT", "dependencies": { + "@types/lodash": "4.14.167", "angular": "1.8.0", "angular-animate": "1.7.9", "angular-local-storage": "0.7.1", @@ -50,10 +51,12 @@ "express-promise-router": "3.0.3", "express-session": "1.15.6", "file-loader": "3.0.1", + "handlebars": "4.7.6", "lodash": "4.17.19", "method-override": "3.0.0", "mongoose": "5.8.1", "nib": "1.1.2", + "nodemailer": "6.4.17", "nyc": "15.1.0", "object-assign": "4.1.1", "passport": "0.4.0", @@ -67,7 +70,9 @@ "redis": "3.0.2", "redlock": "3.1.2", "serve-favicon": "2.5.0", + "short-uuid": "4.1.0", "socket.io": "2.2.0", + "sortablejs": "1.12.0", "style-loader": "0.23.1", "stylus": "0.54.5", "stylus-loader": "3.0.2", @@ -77,8 +82,10 @@ }, "devDependencies": { "@prettier/plugin-pug": "1.10.1", + "babel-eslint": "10.1.0", "chai": "4.2.0", "chai-as-promised": "7.1.1", + "chai-subset": "1.6.0", "coveralls": "3.0.9", "cucumber": "0.10.3", "eslint": "5.14.1", @@ -91,6 +98,7 @@ "sinon": "7.2.2", "sinon-chai": "3.3.0", "supertest": "3.3.0", + "typescript": "4.1.3", "webpack-dev-middleware": "3.5.0", "yaml-lint": "1.2.4" } diff --git a/test/api/election.test.js b/test/api/election.test.js index 62d59e0f..79fab966 100644 --- a/test/api/election.test.js +++ b/test/api/election.test.js @@ -19,6 +19,7 @@ describe('Election API', () => { title: 'activeElection1', description: 'active election 1', active: true, + accessCode: 1234, }; const inactiveElectionData = { @@ -140,6 +141,136 @@ describe('Election API', () => { error.errors.title.kind.should.equal('required'); }); + it('should be able to create elections with one seat', async function () { + passportStub.login(this.adminUser.username); + const { body } = await request(app) + .post('/api/election') + .send({ + title: 'Election', + description: 'ElectionDesc', + seats: 1, + }) + .expect(201) + .expect('Content-Type', /json/); + + body.title.should.equal('Election'); + body.description.should.equal('ElectionDesc'); + body.active.should.equal(false); + }); + + it('should be able to create elections with two seats', async function () { + passportStub.login(this.adminUser.username); + const { body } = await request(app) + .post('/api/election') + .send({ + title: 'Election', + description: 'ElectionDesc', + seats: 2, + }) + .expect(201) + .expect('Content-Type', /json/); + + body.title.should.equal('Election'); + body.description.should.equal('ElectionDesc'); + body.active.should.equal(false); + }); + + it('should return 400 when creating elections with zero seats', async function () { + passportStub.login(this.adminUser.username); + const { body: error } = await request(app) + .post('/api/election') + .send({ + title: 'Election', + description: 'ElectionDesc', + seats: 0, + }) + .expect(400) + .expect('Content-Type', /json/); + + error.name.should.equal('ValidationError'); + error.errors.seats.message.should.equal( + 'An election should have at least one seat' + ); + error.status.should.equal(400); + }); + + it('should return 400 when creating elections with negative seats', async function () { + passportStub.login(this.adminUser.username); + const { body: error } = await request(app) + .post('/api/election') + .send({ + title: 'Election', + description: 'ElectionDesc', + seats: -1, + }) + .expect(400) + .expect('Content-Type', /json/); + + error.name.should.equal('ValidationError'); + error.errors.seats.message.should.equal( + 'An election should have at least one seat' + ); + error.status.should.equal(400); + }); + + it('should be able to create strict elections with one seat', async function () { + passportStub.login(this.adminUser.username); + const { body } = await request(app) + .post('/api/election') + .send({ + title: 'StrictElection', + description: 'StrictElectionDesc', + seats: 1, + useStrict: true, + }) + .expect(201) + .expect('Content-Type', /json/); + + body.title.should.equal('StrictElection'); + body.description.should.equal('StrictElectionDesc'); + body.active.should.equal(false); + }); + + it('should return 400 when creating strict elections with more then one seat', async function () { + passportStub.login(this.adminUser.username); + const { body: error } = await request(app) + .post('/api/election') + .send({ + title: 'StrictElection', + description: 'StrictElectionDesc', + seats: 2, + useStrict: true, + }) + .expect(400) + .expect('Content-Type', /json/); + + error.name.should.equal('ValidationError'); + error.errors.useStrict.message.should.equal( + 'Strict elections must have exactly one seat' + ); + error.status.should.equal(400); + }); + + it('should return 400 when creating strict elections with less then one seat', async function () { + passportStub.login(this.adminUser.username); + const { body: error } = await request(app) + .post('/api/election') + .send({ + title: 'StrictElection', + description: 'StrictElectionDesc', + seats: -1, + useStrict: true, + }) + .expect(400) + .expect('Content-Type', /json/); + + error.name.should.equal('ValidationError'); + error.errors.useStrict.message.should.equal( + 'Strict elections must have exactly one seat' + ); + error.status.should.equal(400); + }); + it('should not be possible to create elections as normal user', async function () { passportStub.login(this.user.username); await testAdminResource('post', '/api/election'); @@ -218,8 +349,24 @@ describe('Election API', () => { await test404('get', '/api/election/badelection', 'election'); }); - it('should be able to activate an election', async function () { + it('should not be possible to have two activate elections', async function () { + passportStub.login(this.adminUser.username); + // There is by default an active election on the database + const election = await Election.create(inactiveElectionData); + await request(app) + .post(`/api/election/${election.id}/activate`) + .expect(409) + .expect('Content-Type', /json/); + ioStub.emit.should.not.have.been.calledWith('election'); + }); + + it('should be possible to activate an election', async function () { + // Deactivate the one default elections + this.activeElection.active = false; + this.activeElection.save(); + passportStub.login(this.adminUser.username); + const election = await Election.create(inactiveElectionData); const { body } = await request(app) .post(`/api/election/${election.id}/activate`) @@ -264,7 +411,7 @@ describe('Election API', () => { .post(`/api/election/${this.activeElection.id}/deactivate`) .expect(200) .expect('Content-Type', /json/); - ioStub.emit.should.not.have.been.called; + ioStub.emit.should.have.been.called; body.active.should.equal(false, 'db election should not be active'); }); @@ -299,14 +446,17 @@ describe('Election API', () => { passportStub.login(this.adminUser.username); const vote = new Vote({ - alternative: this.alternative.id, + priorities: [this.alternative], + election: this.activeElection, hash: 'thisisahash', }); this.activeElection.active = false; await vote.save(); + this.activeElection.votes = [vote]; await this.activeElection.save(); + const { body } = await request(app) .delete(`/api/election/${this.activeElection.id}`) .expect(200) @@ -314,9 +464,11 @@ describe('Election API', () => { body.message.should.equal('Election deleted.'); body.status.should.equal(200); + const elections = await Election.find(); const alternatives = await Alternative.find(); const votes = await Vote.find(); + elections.length.should.equal(0); alternatives.length.should.equal(0); votes.length.should.equal(0); @@ -354,7 +506,7 @@ describe('Election API', () => { await test404('delete', `/api/election/${badId}`, 'election'); }); - it('should be possible to retrieve active elections', async function () { + it('should be possible to retrieve active elections for active user', async function () { passportStub.login(this.user.username); const { body } = await request(app) .get('/api/election/active') @@ -367,24 +519,47 @@ describe('Election API', () => { should.not.exist(body.hasVotedUsers); }); - it('should filter out elections the user has voted on', async function () { + it('should not be possible to retrieve active elections for inactive user', async function () { + this.user.active = false; + await this.user.save(); passportStub.login(this.user.username); - this.activeElection.hasVotedUsers.push({ - user: this.user.id, - }); + await request(app).get('/api/election/active').expect(403); + }); - await this.activeElection.save(); + it('should be possible to retrieve active elections for inactive users with the correct accesscode', async function () { + this.user.active = false; + await this.user.save(); + passportStub.login(this.user.username); const { body } = await request(app) - .get('/api/election/active') + .get('/api/election/active?accessCode=1234') .expect(200) .expect('Content-Type', /json/); - should.not.exist(body); + + body.title.should.equal(this.activeElection.title); + body.description.should.equal(this.activeElection.description); + body.alternatives[0].description.should.equal(this.alternative.description); + should.not.exist(body.hasVotedUsers); + }); + + it('should not be possible to retrieve active elections for inactive users with wrong accesscode', async function () { + this.user.active = false; + await this.user.save(); + passportStub.login(this.user.username); + await request(app).get('/api/election/active?accessCode=1235').expect(403); + }); + + it('should filter out elections the user has voted on', async function () { + passportStub.login(this.user.username); + this.activeElection.hasVotedUsers.push(this.user._id); + + await this.activeElection.save(); + await request(app).get('/api/election/active').expect(404); }); it('should be possible to list the number of users that have voted', async function () { passportStub.login(this.adminUser.username); - await this.alternative.addVote(this.user); + await this.activeElection.addVote(this.user, [this.alternative]); const { body } = await request(app) .get(`/api/election/${this.activeElection.id}/count`) .expect(200) diff --git a/test/api/register.test.js b/test/api/register.test.js new file mode 100644 index 00000000..d66a3660 --- /dev/null +++ b/test/api/register.test.js @@ -0,0 +1,108 @@ +const request = require('supertest'); +const passportStub = require('passport-stub'); +const app = require('../../app'); +const Register = require('../../app/models/register'); +const { createUsers } = require('../helpers'); + +describe('Register API', () => { + before(() => { + passportStub.install(app); + }); + + beforeEach(async function () { + const [user, adminUser, moderatorUser] = await createUsers(); + this.user = user; + this.adminUser = adminUser; + this.moderatorUser = moderatorUser; + + // Create a register and user entry + passportStub.login(this.moderatorUser.username); + await request(app) + .post('/api/user/generate') + .send({ identifier: 'username', email: 'email@domain.com' }); + }); + + after(() => { + passportStub.logout(); + passportStub.uninstall(); + }); + + it('should be possible for a moderator to get a list of registers', async function () { + passportStub.login(this.moderatorUser.username); + const { body } = await request(app) + .get('/api/register') + .expect(200) + .expect('Content-Type', /json/); + body.length.should.equal(1); + body[0].identifier.should.equal('username'); + body[0].email.should.equal('email@domain.com'); + }); + + it('should not be possible for a user to get a list of registers', async function () { + passportStub.login(this.user.username); + const { body: error } = await request(app) + .get('/api/register') + .expect(403) + .expect('Content-Type', /json/); + error.status.should.equal(403); + }); + + it('should be possible for a moderator to delete a register', async function () { + const entry = await Register.findOne({}); + + passportStub.login(this.moderatorUser.username); + const { body } = await request(app) + .delete(`/api/register/${entry._id}`) + .expect(200) + .expect('Content-Type', /json/); + body.status.should.equal(200); + }); + + it('should not be possible for a user to delete a register', async function () { + passportStub.login(this.user.username); + const { body: error } = await request(app) + .delete('/api/register/123') + .expect(403) + .expect('Content-Type', /json/); + error.status.should.equal(403); + }); + + it('should throw ValidationError on invalid registerId', async function () { + passportStub.login(this.moderatorUser.username); + const { body: error } = await request(app) + .delete(`/api/register/wrong123`) + .expect(400) + .expect('Content-Type', /json/); + error.status.should.equal(400); + error.name.should.equal('ValidationError'); + error.message.should.equal('Validation failed.'); + error.errors.should.equal('Invalid ObjectID'); + }); + + it('should throw NotFoundError on wrong registerId', async function () { + passportStub.login(this.moderatorUser.username); + const { body: error } = await request(app) + .delete(`/api/register/601d7354542bba5f8bf4e6f9`) + .expect(404) + .expect('Content-Type', /json/); + error.status.should.equal(404); + error.name.should.equal('NotFoundError'); + error.message.should.equal("Couldn't find register."); + }); + + it('should not be possible for a moderator to delete a register with no user', async function () { + const entry = await Register.findOne({}); + entry.user = null; + await entry.save(); + + passportStub.login(this.moderatorUser.username); + const { body: error } = await request(app) + .delete(`/api/register/${entry._id}`) + .expect(400); + error.status.should.equal(400); + error.name.should.equal('NoAssociatedUserError'); + error.message.should.equal( + "Can't delete a register with no associated user" + ); + }); +}); diff --git a/test/api/user.test.js b/test/api/user.test.js index 5ff456fd..ccccf83f 100644 --- a/test/api/user.test.js +++ b/test/api/user.test.js @@ -4,6 +4,7 @@ const passportStub = require('passport-stub'); const chai = require('chai'); const app = require('../../app'); const User = require('../../app/models/user'); +const Register = require('../../app/models/register'); const { test404, testAdminResource } = require('./helpers'); const { testUser, createUsers } = require('../helpers'); @@ -40,6 +41,11 @@ describe('User API', () => { cardKey: '11TESTCARDKEY', }; + const genUserData = { + identifier: 'identifiername', + email: 'test@user.com', + }; + it('should be possible to create users for admin', async function () { passportStub.login(this.adminUser.username); const { body } = await request(app) @@ -361,4 +367,106 @@ describe('User API', () => { passportStub.login(this.user.username); await testAdminResource('post', '/api/user/deactivate'); }); + + it('should be possible to generate a user while being a moderator', async function () { + passportStub.login(this.moderatorUser.username); + const { body } = await request(app) + .post('/api/user/generate') + .send(genUserData) + .expect(201) + .expect('Content-Type', /json/); + body.status.should.equal('generated'); + body.user.should.equal(genUserData.identifier); + }); + + it('should be not be possible to generate a user for a user', async function () { + passportStub.login(this.user.username); + const { body: error } = await request(app) + .post('/api/user/generate') + .send(genUserData) + .expect(403) + .expect('Content-Type', /json/); + error.name.should.equal('PermissionError'); + error.status.should.equal(403); + }); + + it('should get an error when generating user with no identifier', async function () { + passportStub.login(this.moderatorUser.username); + const { body: error } = await request(app) + .post('/api/user/generate') + .send({ username: 'wrong', email: 'correct@email.com' }) + .expect(400) + .expect('Content-Type', /json/); + error.name.should.equal('InvalidPayloadError'); + error.status.should.equal(400); + error.message.should.equal('Missing property identifier from payload.'); + }); + + it('should get an error when generating user with no email', async function () { + passportStub.login(this.moderatorUser.username); + const { body: error } = await request(app) + .post('/api/user/generate') + .send({ identifier: 'correct', password: 'wrong' }) + .expect(400) + .expect('Content-Type', /json/); + error.name.should.equal('InvalidPayloadError'); + error.status.should.equal(400); + error.message.should.equal('Missing property email from payload.'); + }); + + it('should be possible to generate the same user twice if they are not active', async function () { + passportStub.login(this.moderatorUser.username); + const { body: bodyOne } = await request(app) + .post('/api/user/generate') + .send(genUserData) + .expect(201) + .expect('Content-Type', /json/); + bodyOne.status.should.equal('generated'); + bodyOne.user.should.equal(genUserData.identifier); + + // Check that the register index and the user was created + const register = await Register.findOne({ + identifier: genUserData.identifier, + }); + register.identifier.should.equal(genUserData.identifier); + register.email.should.equal(genUserData.email); + const user = await User.findOne({ _id: register.user }); + should.exist(user); + + // Check that the register index and the user was created + passportStub.login(this.moderatorUser.username); + const { body: bodyTwo } = await request(app) + .post('/api/user/generate') + .send(genUserData) + .expect(201) + .expect('Content-Type', /json/); + bodyTwo.status.should.equal('regenerated'); + bodyTwo.user.should.equal(genUserData.identifier); + }); + + it('should not be possible to generate the same user twice if they are active', async function () { + passportStub.login(this.moderatorUser.username); + const { body: bodyOne } = await request(app) + .post('/api/user/generate') + .send(genUserData) + .expect(201) + .expect('Content-Type', /json/); + bodyOne.status.should.equal('generated'); + bodyOne.user.should.equal(genUserData.identifier); + + // Get the register and fake that they have logged in + const register = await Register.findOne({ + identifier: genUserData.identifier, + }); + register.user = null; + await register.save(); + + // Check that the register index and the user was created + passportStub.login(this.moderatorUser.username); + await request(app) + .post('/api/user/generate') + .send(genUserData) + .expect(409) + .expect('Content-Type', /json/); + }); }); diff --git a/test/api/vote.test.js b/test/api/vote.test.js index 69c8a7b9..bd21b8f4 100644 --- a/test/api/vote.test.js +++ b/test/api/vote.test.js @@ -35,11 +35,12 @@ describe('Vote API', () => { description: 'inactive election alt', }; - function votePayload(alternativeId) { + const votePayload = (inputElection, inputPriorities) => { return { - alternativeId: alternativeId, + election: inputElection, + priorities: inputPriorities, }; - } + }; before(() => { passportStub.install(app); @@ -56,13 +57,15 @@ describe('Vote API', () => { activeData.election = this.activeElection; inactiveData.election = this.inactiveElection; + this.activeAlternative = new Alternative(activeData); this.otherActiveAlternative = new Alternative(otherActiveData); - this.inactiveAlternative = new Alternative(inactiveData); - await this.activeElection.addAlternative(this.activeAlternative); - await this.inactiveElection.addAlternative(this.inactiveAlternative); await this.activeElection.addAlternative(this.otherActiveAlternative); + + this.inactiveAlternative = new Alternative(inactiveData); + await this.inactiveElection.addAlternative(this.inactiveAlternative); + const [user, adminUser, moderatorUser] = await createUsers(); this.user = user; this.adminUser = adminUser; @@ -75,72 +78,181 @@ describe('Vote API', () => { passportStub.uninstall(); }); - it('should not be possible to vote with an invalid ObjectId as alternativeId', async () => { + it('should not be possible to vote without election', async () => { const { body: error } = await request(app) .post('/api/vote') - .send(votePayload('bad alternative')) - .expect(404) + .send({ priorities: [] }) + .expect(400) .expect('Content-Type', /json/); - error.status.should.equal(404); - error.message.should.equal("Couldn't find alternative."); + error.status.should.equal(400); + error.message.should.equal('Missing property election from payload.'); + }); + + it('should not be possible to vote with election that is an array', async () => { + const { body: error } = await request(app) + .post('/api/vote') + .send(votePayload([], [])) + .expect(400) + .expect('Content-Type', /json/); + error.status.should.equal(400); + error.message.should.equal('Missing property election from payload.'); + }); + + it('should not be possible to vote with election that is an string', async () => { + const { body: error } = await request(app) + .post('/api/vote') + .send(votePayload('string', [])) + .expect(400) + .expect('Content-Type', /json/); + error.status.should.equal(400); + error.message.should.equal('Missing property election from payload.'); + }); + + it('should not be possible to vote without priorities', async () => { + const { body: error } = await request(app) + .post('/api/vote') + .send({ election: {} }) + .expect(400) + .expect('Content-Type', /json/); + error.status.should.equal(400); + error.message.should.equal('Missing property priorities from payload.'); + }); + + it('should not be possible to vote with priorities that is not a list', async () => { + const { body: error } = await request(app) + .post('/api/vote') + .send(votePayload({}, '')) + .expect(400) + .expect('Content-Type', /json/); + error.status.should.equal(400); + error.message.should.equal('Missing property priorities from payload.'); }); - it('should not be possible to vote with a nonexistent alternativeId', async () => { + it('should not be possible to vote on a nonexistent election', async () => { const { body: error } = await request(app) .post('/api/vote') - .send(votePayload(new ObjectId())) + .send(votePayload({ _id: new ObjectId() }, [])) .expect(404) .expect('Content-Type', /json/); error.status.should.equal(404); - error.message.should.equal("Couldn't find alternative."); + error.message.should.equal(`Couldn't find election.`); + }); + + it('should not be possible to vote with to many priorities', async function () { + const { body: error } = await request(app) + .post('/api/vote') + .send( + votePayload(this.activeElection, [ + this.activeAlternative, + this.otherActiveAlternative, + this.inactiveAlternative, + ]) + ) + .expect(400) + .expect('Content-Type', /json/); + error.status.should.equal(400); + error.message.should.equal( + 'Priorities is of length 3, election has 2 alternatives.' + ); }); - it('should not be possible to vote without an alternativeId in the payload', async () => { + it('should not be possible to vote with priorities not listed in election', async function () { const { body: error } = await request(app) .post('/api/vote') + .send( + votePayload(this.activeElection, [ + this.activeAlternative, + this.inactiveAlternative, + ]) + ) .expect(400) .expect('Content-Type', /json/); + error.status.should.equal(400); + error.message.should.equal( + 'One or more alternatives does not exist on election.' + ); + }); + it('should not be possible to vote with priorities that are not alternatives', async function () { + const { body: error } = await request(app) + .post('/api/vote') + .send(votePayload(this.activeElection, ['String', {}])) + .expect(400) + .expect('Content-Type', /json/); error.status.should.equal(400); - error.message.should.equal('Missing property alternativeId from payload.'); + error.message.should.equal( + 'One or more alternatives does not exist on election.' + ); }); - it('should be able to vote on alternative', async function () { + it('should be able to vote on active election with an empty priority list', async function () { const { body: vote } = await request(app) .post('/api/vote') - .send(votePayload(this.activeAlternative.id)) + .send(votePayload(this.activeElection, [])) + .expect(201) .expect('Content-Type', /json/); should.exist(vote.hash); - vote.alternative.description.should.equal( - this.activeAlternative.description - ); + vote.priorities.length.should.equal(0); - const votes = await Vote.find({ alternative: this.activeAlternative.id }); + const votes = await Vote.find({ hash: vote.hash }); + votes.length.should.equal(1); + }); + + it('should be able to vote on active election with a priority list shorter then the election', async function () { + const { body: vote } = await request(app) + .post('/api/vote') + .send(votePayload(this.activeElection, [this.activeAlternative])) + .expect(201) + .expect('Content-Type', /json/); + + should.exist(vote.hash); + vote.priorities.length.should.equal(1); + vote.priorities[0].should.equal(this.activeAlternative.id); + + const votes = await Vote.find({ hash: vote.hash }); + votes.length.should.equal(1); + }); + + it('should be able to vote on active election with a full priority list', async function () { + const { body: vote } = await request(app) + .post('/api/vote') + .send( + votePayload(this.activeElection, [ + this.activeAlternative, + this.otherActiveAlternative, + ]) + ) + .expect(201) + .expect('Content-Type', /json/); + + should.exist(vote.hash); + vote.priorities.length.should.equal(2); + vote.priorities[0].should.equal(this.activeAlternative.id); + vote.priorities[1].should.equal(this.otherActiveAlternative.id); + + const votes = await Vote.find({ hash: vote.hash }); votes.length.should.equal(1); }); it('should be able to vote only once', async function () { - await this.activeAlternative.addVote(this.user); + await this.activeElection.addVote(this.user, [this.activeAlternative]); const { body: error } = await request(app) .post('/api/vote') - .send(votePayload(this.otherActiveAlternative.id)) + .send(votePayload(this.activeElection, [this.otherActiveAlternative])) .expect(400) .expect('Content-Type', /json/); error.name.should.equal('AlreadyVotedError'); error.message.should.equal('You can only vote once per election.'); error.status.should.equal(400); - - const votes = await Vote.find({ alternative: this.activeAlternative.id }); - votes.length.should.equal(1); }); it('should not be vulnerable to race conditions', async function () { const create = () => request(app) .post('/api/vote') - .send(votePayload(this.activeAlternative.id)); + .send(votePayload(this.activeElection, [this.activeAlternative])); await Promise.all([ create(), create(), @@ -153,7 +265,7 @@ describe('Vote API', () => { create(), create(), ]); - const votes = await Vote.find({ alternative: this.activeAlternative.id }); + const votes = await Vote.find({ election: this.activeElection._id }); votes.length.should.equal(1); }); @@ -161,7 +273,7 @@ describe('Vote API', () => { passportStub.logout(); const { body: error } = await request(app) .post('/api/vote') - .send(votePayload(this.activeAlternative.id)) + .send(votePayload(this.activeElection, [this.activeAlternative])) .expect(401) .expect('Content-Type', /json/); error.status.should.equal(401); @@ -175,7 +287,7 @@ describe('Vote API', () => { await this.user.save(); const { body: error } = await request(app) .post('/api/vote') - .send(votePayload(this.activeAlternative.id)) + .send(votePayload(this.activeElection, [])) .expect(403) .expect('Content-Type', /json/); error.message.should.equal( @@ -190,7 +302,7 @@ describe('Vote API', () => { it('should not be able to vote on a deactivated election', async function () { const { body: error } = await request(app) .post('/api/vote') - .send(votePayload(this.inactiveAlternative.id)) + .send(votePayload(this.inactiveElection, [])) .expect(400) .expect('Content-Type', /json/); error.name.should.equal('InactiveElectionError'); @@ -201,20 +313,33 @@ describe('Vote API', () => { votes.length.should.equal(0, 'no vote should be added'); }); - it('should be possible to retrieve a vote', async function () { - const vote = await this.activeAlternative.addVote(this.user); + it('should be possible to retrieve a vote with hash', async function () { + const vote = await this.activeElection.addVote(this.user, []); const { body: receivedVote } = await request(app) .get('/api/vote') .set('Vote-Hash', vote.hash) .expect(200) .expect('Content-Type', /json/); - receivedVote.hash.should.equal(vote.hash); - receivedVote.alternative._id.should.equal(String(vote.alternative)); - receivedVote.alternative.election.should.deep.equal({ - _id: String(this.activeElection.id), - title: this.activeElection.title, - }); + }); + + it('should be possible to retrieve a vote with correct election', async function () { + const vote = await this.activeElection.addVote(this.user, [ + this.activeAlternative, + this.otherActiveAlternative, + ]); + const { body: receivedVote } = await request(app) + .get('/api/vote') + .set('Vote-Hash', vote.hash) + .expect(200) + .expect('Content-Type', /json/); + receivedVote.election._id.should.equal(String(this.activeElection.id)); + receivedVote.election.title.should.equal(String(this.activeElection.title)); + receivedVote.priorities.length.should.equal(2); + receivedVote.priorities[0].description.should.equal(activeData.description); + receivedVote.priorities[1].description.should.equal( + otherActiveData.description + ); }); it('should return 400 when retrieving votes without header', async () => { @@ -230,14 +355,18 @@ describe('Vote API', () => { it('should be possible to sum votes', async function () { passportStub.login(this.adminUser.username); - await this.otherActiveAlternative.addVote(this.user); + await this.activeElection.addVote(this.user, [ + this.activeAlternative, + this.otherActiveAlternative, + ]); + this.activeElection.active = false; + await this.activeElection.save(); const { body } = await request(app) - .get(`/api/election/${this.inactiveElection.id}/votes`) + .get(`/api/election/${this.activeElection.id}/votes`) .expect(200) .expect('Content-Type', /json/); - body.length.should.equal(1); - body[0].votes.should.equal(0); + body.result.status.should.equal('RESOLVED'); }); it('should not be possible to get votes on an active election', async function () { @@ -281,7 +410,7 @@ describe('Vote API', () => { passportStub.login(this.adminUser.username); const { body: error } = await request(app) .post('/api/vote') - .send(votePayload(this.activeAlternative.id)) + .send(votePayload(this.activeElection, [])) .expect(403) .expect('Content-Type', /json/); @@ -294,7 +423,7 @@ describe('Vote API', () => { passportStub.login(this.moderatorUser.username); const { body: error } = await request(app) .post('/api/vote') - .send(votePayload(this.activeAlternative.id)) + .send(votePayload(this.activeElection, [])) .expect(403) .expect('Content-Type', /json/); diff --git a/test/helpers.js b/test/helpers.js index 1d494c57..e6c152be 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -4,12 +4,14 @@ const Alternative = require('../app/models/alternative'); const Election = require('../app/models/election'); const Vote = require('../app/models/vote'); const User = require('../app/models/user'); +const Register = require('../app/models/register'); +const crypto = require('crypto'); exports.dropDatabase = () => mongoose.connection.dropDatabase().then(() => mongoose.disconnect()); exports.clearCollections = () => - Bluebird.map([Alternative, Election, Vote, User], (collection) => + Bluebird.map([Alternative, Register, Election, Vote, User], (collection) => collection.deleteMany() ); @@ -37,3 +39,63 @@ const moderatorUser = (exports.moderatorUser = { }); exports.createUsers = () => User.create([testUser, adminUser, moderatorUser]); + +const prepareElection = async function (dataset) { + // Takes the priorities from the dataset, as well as the amount of times + // that priority combination should be repeated. This basically transforms + // the dataset from a reduced format into the format used by the .elect() + // method for a normal Vote-STV election. The reason the dataset are written + // on the reduced format is for convenience, as it would be to hard to read + // datasets with thousands of duplicate lines + const repeat = async (priorities, amount) => { + const resolvedAlternatives = await Promise.all( + priorities.map((d) => Alternative.findOne({ description: d })) + ); + return new Array(amount).fill(resolvedAlternatives); + }; + + // Step 1) Create Election + const election = await Election.create({ + title: 'Title', + description: 'Description', + active: true, + seats: dataset.seats, + useStrict: dataset.useStrict, + }); + // Step 2) Mutate alternatives and create a new Alternative for each + const alternatives = await Promise.all( + dataset.alternatives + .map((a) => ({ + election: election._id, + description: a, + })) + .map((a) => new Alternative(a)) + ); + // Step 3) Update election with alternatives + for (let i = 0; i < alternatives.length; i++) { + await election.addAlternative(alternatives[i]); + } + + // Step 4) Create priorities from dataset + const allPriorities = await Promise.all( + dataset.priorities.flatMap((entry) => repeat(entry.priority, entry.amount)) + ); + // Step 5) Use the resolved priorities to create vote ballots + const resolvedVotes = await Promise.all( + allPriorities.flat().map((priorities) => + new Vote({ + hash: crypto.randomBytes(12).toString('hex'), + priorities, + }).save() + ) + ); + + // Set the votes and deactivate the election before saving + election.votes = resolvedVotes; + election.active = false; + await election.save(); + + return election; +}; + +exports.prepareElection = prepareElection; diff --git a/test/stv/blank.test.js b/test/stv/blank.test.js new file mode 100644 index 00000000..7619babf --- /dev/null +++ b/test/stv/blank.test.js @@ -0,0 +1,139 @@ +const dataset = require('./datasets'); +const { prepareElection } = require('../helpers'); + +describe('STV Blank Logic', () => { + it('should not resolve for the election in dataset 11 with blank votes', async function () { + const election = await prepareElection(dataset.dataset11); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 13, + seats: 1, + voteCount: 24, + blankVoteCount: 7, + useStrict: false, + result: { + status: 'UNRESOLVED', + winners: [], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + A: 10, + B: 7, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'B' }, + minScore: 7, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [], + counts: { + A: 10, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'A' }, + minScore: 10, + }, + ], + }); + }); + + it('should resolve with expected leader election (with blank votes)', async function () { + const election = await prepareElection(dataset.dataset12); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 77, + seats: 1, + voteCount: 153, + blankVoteCount: 11, + useStrict: false, + result: { + status: 'RESOLVED', + winners: [{ description: 'B' }], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + A: 70, + B: 72, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'A' }, + minScore: 70, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [], + counts: { + B: 119, + }, + }, + { + action: 'WIN', + alternative: { description: 'B' }, + voteCount: 119, + }, + ], + }); + }); + + it('should not resolve for a strict election with blank votes', async function () { + const election = await prepareElection(dataset.dataset13); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 104, + seats: 1, + voteCount: 155, + blankVoteCount: 23, + useStrict: true, + result: { + status: 'RESOLVED', + winners: [{ description: 'Fjerne' }], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + Fjerne: 93, + Bytte: 39, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'Bytte' }, + minScore: 39, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [], + counts: { + Fjerne: 104, + }, + }, + { + action: 'WIN', + alternative: { description: 'Fjerne' }, + voteCount: 104, + }, + ], + }); + }); +}); diff --git a/test/stv/datasets/dataset1.js b/test/stv/datasets/dataset1.js new file mode 100644 index 00000000..ffcb200e --- /dev/null +++ b/test/stv/datasets/dataset1.js @@ -0,0 +1,19 @@ +// Dataset from https://en.wikipedia.org/wiki/Droop_quota +module.exports = { + seats: 2, + alternatives: ['Andrea', 'Carter', 'Brad'], + priorities: [ + { + priority: ['Andrea', 'Carter'], + amount: 45, + }, + { + priority: ['Carter'], + amount: 25, + }, + { + priority: ['Brad'], + amount: 30, + }, + ], +}; diff --git a/test/stv/datasets/dataset10.js b/test/stv/datasets/dataset10.js new file mode 100644 index 00000000..768e12eb --- /dev/null +++ b/test/stv/datasets/dataset10.js @@ -0,0 +1,15 @@ +module.exports = { + seats: 1, + useStrict: true, + alternatives: ['A', 'B'], + priorities: [ + { + priority: ['A'], + amount: 21, + }, + { + priority: ['B'], + amount: 10, + }, + ], +}; diff --git a/test/stv/datasets/dataset11.js b/test/stv/datasets/dataset11.js new file mode 100644 index 00000000..99f14183 --- /dev/null +++ b/test/stv/datasets/dataset11.js @@ -0,0 +1,17 @@ +module.exports = { + alternatives: ['A', 'B'], + priorities: [ + { + priority: ['A'], + amount: 10, + }, + { + priority: ['B'], + amount: 7, + }, + { + priority: [], + amount: 7, + }, + ], +}; diff --git a/test/stv/datasets/dataset12.js b/test/stv/datasets/dataset12.js new file mode 100644 index 00000000..540564ac --- /dev/null +++ b/test/stv/datasets/dataset12.js @@ -0,0 +1,26 @@ +module.exports = { + seats: 1, + alternatives: ['A', 'B'], + priorities: [ + { + priority: ['A'], + amount: 23, + }, + { + priority: ['B'], + amount: 39, + }, + { + priority: ['A', 'B'], + amount: 47, + }, + { + priority: ['B', 'A'], + amount: 33, + }, + { + priority: [], + amount: 11, + }, + ], +}; diff --git a/test/stv/datasets/dataset13.js b/test/stv/datasets/dataset13.js new file mode 100644 index 00000000..d0343293 --- /dev/null +++ b/test/stv/datasets/dataset13.js @@ -0,0 +1,26 @@ +module.exports = { + alternatives: ['Bytte', 'Fjerne'], + useStrict: true, + priorities: [ + { + priority: ['Bytte'], + amount: 28, + }, + { + priority: ['Fjerne'], + amount: 56, + }, + { + priority: ['Bytte', 'Fjerne'], + amount: 11, + }, + { + priority: ['Fjerne', 'Bytte'], + amount: 37, + }, + { + priority: [], + amount: 23, + }, + ], +}; diff --git a/test/stv/datasets/dataset2.js b/test/stv/datasets/dataset2.js new file mode 100644 index 00000000..3b5442cf --- /dev/null +++ b/test/stv/datasets/dataset2.js @@ -0,0 +1,31 @@ +// Dataset from https://en.wikipedia.org/wiki/Single_transferable_vote +module.exports = { + seats: 3, + alternatives: ['Orange', 'Pear', 'Chocolate', 'Strawberry', 'Hamburger'], + priorities: [ + { + priority: ['Orange'], + amount: 4, + }, + { + priority: ['Pear', 'Orange'], + amount: 2, + }, + { + priority: ['Chocolate', 'Strawberry'], + amount: 8, + }, + { + priority: ['Chocolate', 'Hamburger'], + amount: 4, + }, + { + priority: ['Strawberry'], + amount: 1, + }, + { + priority: ['Hamburger'], + amount: 1, + }, + ], +}; diff --git a/test/stv/datasets/dataset3.js b/test/stv/datasets/dataset3.js new file mode 100644 index 00000000..72a7d3ce --- /dev/null +++ b/test/stv/datasets/dataset3.js @@ -0,0 +1,117 @@ +// Dataset from https://www.iiconsortium.org/Single_Transferable_Vote.pdf +module.exports = { + seats: 5, + alternatives: [ + 'STEWART', + 'VINE', + 'AUGUSTINE', + 'COHEN', + 'LENNON', + 'EVANS', + 'WILCOCKS', + 'HARLEY', + 'PEARSON', + ], + priorities: [ + { + priority: ['STEWART', 'AUGUSTINE'], + amount: 66, + }, + { + priority: ['VINE'], + amount: 48, + }, + { + priority: ['AUGUSTINE'], + amount: 95, + }, + { + priority: ['COHEN'], + amount: 55, + }, + { + priority: ['LENNON'], + amount: 4, + }, + { + priority: ['LENNON', 'STEWART', 'AUGUSTINE'], + amount: 46, + }, + { + priority: ['LENNON', 'VINE'], + amount: 6, + }, + { + priority: ['LENNON', 'COHEN'], + amount: 2, + }, + { + priority: ['EVANS', 'VINE'], + amount: 80, + }, + { + priority: ['EVANS', 'COHEN'], + amount: 36, + }, + { + priority: ['EVANS', 'PEARSON', 'VINE'], + amount: 16, + }, + { + priority: ['EVANS', 'STEWART', 'AUGUSTINE'], + amount: 8, + }, + { + priority: ['EVANS', 'HARLEY'], + amount: 4, + }, + { + priority: ['WILCOCKS'], + amount: 5, + }, + { + priority: ['WILCOCKS', 'AUGUSTINE'], + amount: 32, + }, + { + priority: ['WILCOCKS', 'HARLEY'], + amount: 15, + }, + { + priority: ['WILCOCKS', 'VINE'], + amount: 7, + }, + { + priority: ['WILCOCKS', 'COHEN'], + amount: 1, + }, + { + priority: ['HARLEY'], + amount: 91, + }, + { + priority: ['PEARSON'], + amount: 3, + }, + { + priority: ['PEARSON', 'STEWART', 'AUGUSTINE'], + amount: 1, + }, + { + priority: ['PEARSON', 'VINE'], + amount: 19, + }, + { + priority: ['PEARSON', 'AUGUSTINE'], + amount: 1, + }, + { + priority: ['PEARSON', 'COHEN'], + amount: 5, + }, + { + priority: ['PEARSON', 'HARLEY'], + amount: 1, + }, + ], +}; diff --git a/test/stv/datasets/dataset4.js b/test/stv/datasets/dataset4.js new file mode 100644 index 00000000..08c7cd80 --- /dev/null +++ b/test/stv/datasets/dataset4.js @@ -0,0 +1,31 @@ +// Dataset from "Created ourselves" +module.exports = { + seats: 1, + alternatives: ['Bent Høye', 'Siv Jensen', 'Erna Solberg'], + priorities: [ + { + priority: ['Erna Solberg'], + amount: 24, + }, + { + priority: ['Erna Solberg', 'Siv Jensen'], + amount: 64, + }, + { + priority: ['Bent Høye'], + amount: 51, + }, + { + priority: ['Bent Høye', 'Erna Solberg'], + amount: 21, + }, + { + priority: ['Siv Jensen', 'Erna Solberg', 'Bent Høye'], + amount: 18, + }, + { + priority: ['Siv Jensen', 'Erna Solberg'], + amount: 26, + }, + ], +}; diff --git a/test/stv/datasets/dataset5.js b/test/stv/datasets/dataset5.js new file mode 100644 index 00000000..2a569377 --- /dev/null +++ b/test/stv/datasets/dataset5.js @@ -0,0 +1,86 @@ +module.exports = { + seats: 2, + alternatives: ['A', 'B', 'C', 'D'], + priorities: [ + { + priority: ['B'], + amount: 7, + }, + { + priority: ['C'], + amount: 4, + }, + { + priority: ['D'], + amount: 3, + }, + { + priority: ['A', 'B'], + amount: 3, + }, + { + priority: ['A', 'C', 'B'], // (*) + amount: 3, + }, + { + priority: ['A', 'D', 'B'], // (*) + amount: 3, + }, + ], +}; + +/** This dataset is specially created in order to get floating point errors that causes + * a candidate to not reach the quota. They are actually very common, but can be hard + * to spot. Therefore it's important that test cases like the one above passes + * + * ============================================================================= + * + * So with the test above there are 2 seats and a total of 23 votes. + * + * This will give a quota of Floor(23/(2+1)) + 1 which is 8 + * + * ============================================================================= + * + * The iterations below is what will happen if floating point errors are not handled + * + * ITERATION 1) + * The counts are as follows {A: 9, B: 7, C: 4, D: 3 } + * 'A' has a voteCount of 9.000 and will be a winner right away. + * + * ITERATION 2) + * The counts are as follows { B: 7.333333333333332, C: 4.333333333333332, D: 3.3333333333333335 } + * Looks correct? Yeah, B,C and D has gotten 1/3 of the 1 excess vote 'A' had. + * None have reached the quota, so the candidate with the lowest score is eliminated. + * + * ITERATION 3) + * The counts are now as follows { B: 7.666666666666664, C: 4.333333333333332 } + * Again this looks correct? 'B' has gotten the 1/3 vote given from 'A' to 'D' + * None have reached the quota, so the candidate with the lowest score is eliminated. + * + * ITERATION 4) + * The counts are as follows { B: 7.9999999999999964 } + * Hmmmmm? B gets 7.99..964. So B has gotten the final 1/3 of the excess 'A' vote. + * + * But B has NOT reached the quota, as the quota is 8... This is where the floating point + * errors can cause trouble. Even tho 'A' had 1 whole excess vote it was split into tree + * parts and given to 'B', 'C' and 'D'. As the election plays out it becomes apparent that + * 'C' and 'D' cannot win, and that 1/3 vote passes to 'B', which is next in line (see (*)) + * + * Summing up the votes should give (1/3 + 1/3 + 1/3) == 1 should give, but this is not the case. + * + * ITERATION 5) + * Election is UNRESOLVED and end with only 'A' winning, even though 'B' also reached the quota + * + * ============================================================================= + * + * Conclusion: So the iterations above will still happen with our Implementation of STV, + * but we have countered this by using an EPSILON value with each comparison. The EPSILON + * is a very small value, and when added or subtracted within a comparison can mitigate + * errors caused by floating point errors. + * + * This means + * we never check + * if ( x > y) + * but rather check + * if (x > (y - EPSILON)) + */ diff --git a/test/stv/datasets/dataset6.js b/test/stv/datasets/dataset6.js new file mode 100644 index 00000000..3e89e9c0 --- /dev/null +++ b/test/stv/datasets/dataset6.js @@ -0,0 +1,72 @@ +// Dataset created to show floating point errors +module.exports = { + seats: 2, + alternatives: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'], + priorities: [ + { + priority: ['B'], + amount: 40, + }, + { + priority: ['C'], + amount: 40, + }, + { + priority: ['D'], + amount: 40, + }, + { + priority: ['A', 'B'], + amount: 30, + }, + { + priority: ['A', 'C'], + amount: 30, + }, + { + priority: ['A', 'E', 'D'], + amount: 17, + }, + { + priority: ['A', 'F', 'D', 'G'], + amount: 6, + }, + { + priority: ['A', 'F', 'D', 'H'], + amount: 7, + }, + ], +}; + +/** This dataset is specially created in order to get floating point errors that causes + * two candidates to have the same low value. + * + * ============================================================================= + * + * So with the test above there are 2 seats and a total of 210 votes. + * + * This will give a quota of Floor(210/(2+1)) + 1 which is 71 + * + * ============================================================================= + * + * When adding up the multiple fractions created above the result should be that the + * 3 bottom candidates 'B', 'C' and 'D' at some point should have 46.33333333333326 + * + * Therefore it's important that this case ensures that all 3 candidates are treated + * equal at this iteration. + * + * At the point above (iteration 5), all candidates have the same score, and we + * must issue a "TIE". But before we abort the election and return UNRESOLVED we + * can use backtracking to check if the election ever had a state where the + * candidates had unequal score. This is the Scottish STV method for breaking TIES. + * + * In this case we iterate backward and find that one iteration back the score + * looked like this: + counts: { + B: 46.3333, + C: 46.3333, + D: 42.7444, + E: 3.5889, + }, + * Therefore, according to the algorithm, D can be eliminated with a score of 42.7 + */ diff --git a/test/stv/datasets/dataset7.js b/test/stv/datasets/dataset7.js new file mode 100644 index 00000000..466a03c3 --- /dev/null +++ b/test/stv/datasets/dataset7.js @@ -0,0 +1,15 @@ +module.exports = { + seats: 1, + useStrict: true, + alternatives: ['A', 'B'], + priorities: [ + { + priority: ['A'], + amount: 10, + }, + { + priority: ['B'], + amount: 10, + }, + ], +}; diff --git a/test/stv/datasets/dataset8.js b/test/stv/datasets/dataset8.js new file mode 100644 index 00000000..d96af9b4 --- /dev/null +++ b/test/stv/datasets/dataset8.js @@ -0,0 +1,15 @@ +module.exports = { + seats: 1, + useStrict: true, + alternatives: ['A', 'B'], + priorities: [ + { + priority: ['A'], + amount: 11, + }, + { + priority: ['B'], + amount: 10, + }, + ], +}; diff --git a/test/stv/datasets/dataset9.js b/test/stv/datasets/dataset9.js new file mode 100644 index 00000000..4318b367 --- /dev/null +++ b/test/stv/datasets/dataset9.js @@ -0,0 +1,15 @@ +module.exports = { + seats: 1, + useStrict: true, + alternatives: ['A', 'B'], + priorities: [ + { + priority: ['A'], + amount: 20, + }, + { + priority: ['B'], + amount: 10, + }, + ], +}; diff --git a/test/stv/datasets/datasetOpaVote.js b/test/stv/datasets/datasetOpaVote.js new file mode 100644 index 00000000..71c55f72 --- /dev/null +++ b/test/stv/datasets/datasetOpaVote.js @@ -0,0 +1,332 @@ +module.exports = { + seats: 2, + alternatives: ['Steve', 'Bill', 'Elon', 'Warren', 'Richard'], + priorities: [ + { priority: [''], amount: 1614 }, + { priority: ['Bill', 'Elon', 'Richard', 'Steve', 'Warren'], amount: 48 }, + { priority: ['Bill', 'Elon', 'Richard', 'Steve'], amount: 11 }, + { priority: ['Bill', 'Elon', 'Richard', 'Warren', 'Steve'], amount: 34 }, + { priority: ['Bill', 'Elon', 'Richard', 'Warren'], amount: 2 }, + { priority: ['Bill', 'Elon', 'Richard'], amount: 25 }, + { priority: ['Bill', 'Elon', 'Steve', 'Richard', 'Warren'], amount: 44 }, + { priority: ['Bill', 'Elon', 'Steve', 'Richard'], amount: 8 }, + { priority: ['Bill', 'Elon', 'Steve', 'Warren', 'Richard'], amount: 54 }, + { priority: ['Bill', 'Elon', 'Steve', 'Warren'], amount: 6 }, + { priority: ['Bill', 'Elon', 'Steve'], amount: 37 }, + { priority: ['Bill', 'Elon', 'Warren', 'Richard', 'Steve'], amount: 43 }, + { priority: ['Bill', 'Elon', 'Warren', 'Richard'], amount: 8 }, + { priority: ['Bill', 'Elon', 'Warren', 'Steve', 'Richard'], amount: 51 }, + { priority: ['Bill', 'Elon', 'Warren', 'Steve'], amount: 5 }, + { priority: ['Bill', 'Elon', 'Warren'], amount: 24 }, + { priority: ['Bill', 'Elon'], amount: 55 }, + { priority: ['Bill', 'Richard', 'Elon', 'Steve', 'Warren'], amount: 29 }, + { priority: ['Bill', 'Richard', 'Elon', 'Steve'], amount: 4 }, + { priority: ['Bill', 'Richard', 'Elon', 'Warren', 'Steve'], amount: 41 }, + { priority: ['Bill', 'Richard', 'Elon', 'Warren'], amount: 1 }, + { priority: ['Bill', 'Richard', 'Elon'], amount: 16 }, + { priority: ['Bill', 'Richard', 'Steve', 'Elon', 'Warren'], amount: 27 }, + { priority: ['Bill', 'Richard', 'Steve', 'Elon'], amount: 4 }, + { priority: ['Bill', 'Richard', 'Steve', 'Warren', 'Elon'], amount: 35 }, + { priority: ['Bill', 'Richard', 'Steve', 'Warren'], amount: 8 }, + { priority: ['Bill', 'Richard', 'Steve'], amount: 23 }, + { priority: ['Bill', 'Richard', 'Warren', 'Elon', 'Steve'], amount: 34 }, + { priority: ['Bill', 'Richard', 'Warren', 'Elon'], amount: 3 }, + { priority: ['Bill', 'Richard', 'Warren', 'Steve', 'Elon'], amount: 40 }, + { priority: ['Bill', 'Richard', 'Warren', 'Steve'], amount: 6 }, + { priority: ['Bill', 'Richard', 'Warren'], amount: 22 }, + { priority: ['Bill', 'Richard'], amount: 50 }, + { priority: ['Bill', 'Steve', 'Elon', 'Richard', 'Warren'], amount: 50 }, + { priority: ['Bill', 'Steve', 'Elon', 'Richard'], amount: 5 }, + { priority: ['Bill', 'Steve', 'Elon', 'Warren', 'Richard'], amount: 72 }, + { priority: ['Bill', 'Steve', 'Elon', 'Warren'], amount: 4 }, + { priority: ['Bill', 'Steve', 'Elon'], amount: 36 }, + { priority: ['Bill', 'Steve', 'Richard', 'Elon', 'Warren'], amount: 29 }, + { priority: ['Bill', 'Steve', 'Richard', 'Elon'], amount: 5 }, + { priority: ['Bill', 'Steve', 'Richard', 'Warren', 'Elon'], amount: 59 }, + { priority: ['Bill', 'Steve', 'Richard', 'Warren'], amount: 8 }, + { priority: ['Bill', 'Steve', 'Richard'], amount: 22 }, + { priority: ['Bill', 'Steve', 'Warren', 'Elon', 'Richard'], amount: 53 }, + { priority: ['Bill', 'Steve', 'Warren', 'Elon'], amount: 6 }, + { priority: ['Bill', 'Steve', 'Warren', 'Richard', 'Elon'], amount: 80 }, + { priority: ['Bill', 'Steve', 'Warren', 'Richard'], amount: 4 }, + { priority: ['Bill', 'Steve', 'Warren'], amount: 41 }, + { priority: ['Bill', 'Steve'], amount: 95 }, + { priority: ['Bill', 'Warren', 'Elon', 'Richard', 'Steve'], amount: 60 }, + { priority: ['Bill', 'Warren', 'Elon', 'Richard'], amount: 2 }, + { priority: ['Bill', 'Warren', 'Elon', 'Steve', 'Richard'], amount: 59 }, + { priority: ['Bill', 'Warren', 'Elon', 'Steve'], amount: 4 }, + { priority: ['Bill', 'Warren', 'Elon'], amount: 33 }, + { priority: ['Bill', 'Warren', 'Richard', 'Elon', 'Steve'], amount: 52 }, + { priority: ['Bill', 'Warren', 'Richard', 'Elon'], amount: 3 }, + { priority: ['Bill', 'Warren', 'Richard', 'Steve', 'Elon'], amount: 64 }, + { priority: ['Bill', 'Warren', 'Richard', 'Steve'], amount: 7 }, + { priority: ['Bill', 'Warren', 'Richard'], amount: 38 }, + { priority: ['Bill', 'Warren', 'Steve', 'Elon', 'Richard'], amount: 68 }, + { priority: ['Bill', 'Warren', 'Steve', 'Elon'], amount: 7 }, + { priority: ['Bill', 'Warren', 'Steve', 'Richard', 'Elon'], amount: 80 }, + { priority: ['Bill', 'Warren', 'Steve', 'Richard'], amount: 6 }, + { priority: ['Bill', 'Warren', 'Steve'], amount: 46 }, + { priority: ['Bill', 'Warren'], amount: 142 }, + { priority: ['Bill'], amount: 181 }, + { priority: ['Elon', 'Bill', 'Richard', 'Steve', 'Warren'], amount: 40 }, + { priority: ['Elon', 'Bill', 'Richard', 'Steve'], amount: 3 }, + { priority: ['Elon', 'Bill', 'Richard', 'Warren', 'Steve'], amount: 39 }, + { priority: ['Elon', 'Bill', 'Richard', 'Warren'], amount: 5 }, + { priority: ['Elon', 'Bill', 'Richard'], amount: 30 }, + { priority: ['Elon', 'Bill', 'Steve', 'Richard', 'Warren'], amount: 55 }, + { priority: ['Elon', 'Bill', 'Steve', 'Richard'], amount: 5 }, + { priority: ['Elon', 'Bill', 'Steve', 'Warren', 'Richard'], amount: 83 }, + { priority: ['Elon', 'Bill', 'Steve', 'Warren'], amount: 11 }, + { priority: ['Elon', 'Bill', 'Steve'], amount: 48 }, + { priority: ['Elon', 'Bill', 'Warren', 'Richard', 'Steve'], amount: 44 }, + { priority: ['Elon', 'Bill', 'Warren', 'Richard'], amount: 2 }, + { priority: ['Elon', 'Bill', 'Warren', 'Steve', 'Richard'], amount: 56 }, + { priority: ['Elon', 'Bill', 'Warren', 'Steve'], amount: 8 }, + { priority: ['Elon', 'Bill', 'Warren'], amount: 27 }, + { priority: ['Elon', 'Bill'], amount: 73 }, + { priority: ['Elon', 'Richard', 'Bill', 'Steve', 'Warren'], amount: 38 }, + { priority: ['Elon', 'Richard', 'Bill', 'Steve'], amount: 4 }, + { priority: ['Elon', 'Richard', 'Bill', 'Warren', 'Steve'], amount: 43 }, + { priority: ['Elon', 'Richard', 'Bill', 'Warren'], amount: 3 }, + { priority: ['Elon', 'Richard', 'Bill'], amount: 23 }, + { priority: ['Elon', 'Richard', 'Steve', 'Bill', 'Warren'], amount: 42 }, + { priority: ['Elon', 'Richard', 'Steve', 'Bill'], amount: 3 }, + { priority: ['Elon', 'Richard', 'Steve', 'Warren', 'Bill'], amount: 49 }, + { priority: ['Elon', 'Richard', 'Steve', 'Warren'], amount: 7 }, + { priority: ['Elon', 'Richard', 'Steve'], amount: 32 }, + { priority: ['Elon', 'Richard', 'Warren', 'Bill', 'Steve'], amount: 27 }, + { priority: ['Elon', 'Richard', 'Warren', 'Bill'], amount: 3 }, + { priority: ['Elon', 'Richard', 'Warren', 'Steve', 'Bill'], amount: 29 }, + { priority: ['Elon', 'Richard', 'Warren', 'Steve'], amount: 3 }, + { priority: ['Elon', 'Richard', 'Warren'], amount: 19 }, + { priority: ['Elon', 'Richard'], amount: 57 }, + { priority: ['Elon', 'Steve', 'Bill', 'Richard', 'Warren'], amount: 52 }, + { priority: ['Elon', 'Steve', 'Bill', 'Richard'], amount: 4 }, + { priority: ['Elon', 'Steve', 'Bill', 'Warren', 'Richard'], amount: 76 }, + { priority: ['Elon', 'Steve', 'Bill', 'Warren'], amount: 11 }, + { priority: ['Elon', 'Steve', 'Bill'], amount: 40 }, + { priority: ['Elon', 'Steve', 'Richard', 'Bill', 'Warren'], amount: 39 }, + { priority: ['Elon', 'Steve', 'Richard', 'Bill'], amount: 10 }, + { priority: ['Elon', 'Steve', 'Richard', 'Warren', 'Bill'], amount: 43 }, + { priority: ['Elon', 'Steve', 'Richard', 'Warren'], amount: 7 }, + { priority: ['Elon', 'Steve', 'Richard'], amount: 30 }, + { priority: ['Elon', 'Steve', 'Warren', 'Bill', 'Richard'], amount: 49 }, + { priority: ['Elon', 'Steve', 'Warren', 'Bill'], amount: 3 }, + { priority: ['Elon', 'Steve', 'Warren', 'Richard', 'Bill'], amount: 45 }, + { priority: ['Elon', 'Steve', 'Warren', 'Richard'], amount: 3 }, + { priority: ['Elon', 'Steve', 'Warren'], amount: 23 }, + { priority: ['Elon', 'Steve'], amount: 75 }, + { priority: ['Elon', 'Warren', 'Bill', 'Richard', 'Steve'], amount: 47 }, + { priority: ['Elon', 'Warren', 'Bill', 'Richard'], amount: 4 }, + { priority: ['Elon', 'Warren', 'Bill', 'Steve', 'Richard'], amount: 47 }, + { priority: ['Elon', 'Warren', 'Bill', 'Steve'], amount: 4 }, + { priority: ['Elon', 'Warren', 'Bill'], amount: 24 }, + { priority: ['Elon', 'Warren', 'Richard', 'Bill', 'Steve'], amount: 34 }, + { priority: ['Elon', 'Warren', 'Richard', 'Bill'], amount: 3 }, + { priority: ['Elon', 'Warren', 'Richard', 'Steve', 'Bill'], amount: 39 }, + { priority: ['Elon', 'Warren', 'Richard', 'Steve'], amount: 2 }, + { priority: ['Elon', 'Warren', 'Richard'], amount: 25 }, + { priority: ['Elon', 'Warren', 'Steve', 'Bill', 'Richard'], amount: 27 }, + { priority: ['Elon', 'Warren', 'Steve', 'Bill'], amount: 2 }, + { priority: ['Elon', 'Warren', 'Steve', 'Richard', 'Bill'], amount: 30 }, + { priority: ['Elon', 'Warren', 'Steve', 'Richard'], amount: 6 }, + { priority: ['Elon', 'Warren', 'Steve'], amount: 22 }, + { priority: ['Elon', 'Warren'], amount: 54 }, + { priority: ['Elon'], amount: 135 }, + { priority: ['Richard', 'Bill', 'Elon', 'Steve', 'Warren'], amount: 28 }, + { priority: ['Richard', 'Bill', 'Elon', 'Steve'], amount: 4 }, + { priority: ['Richard', 'Bill', 'Elon', 'Warren', 'Steve'], amount: 24 }, + { priority: ['Richard', 'Bill', 'Elon', 'Warren'], amount: 3 }, + { priority: ['Richard', 'Bill', 'Elon'], amount: 25 }, + { priority: ['Richard', 'Bill', 'Steve', 'Elon', 'Warren'], amount: 30 }, + { priority: ['Richard', 'Bill', 'Steve', 'Elon'], amount: 3 }, + { priority: ['Richard', 'Bill', 'Steve', 'Warren', 'Elon'], amount: 39 }, + { priority: ['Richard', 'Bill', 'Steve', 'Warren'], amount: 4 }, + { priority: ['Richard', 'Bill', 'Steve'], amount: 22 }, + { priority: ['Richard', 'Bill', 'Warren', 'Elon', 'Steve'], amount: 31 }, + { priority: ['Richard', 'Bill', 'Warren', 'Elon'], amount: 7 }, + { priority: ['Richard', 'Bill', 'Warren', 'Steve', 'Elon'], amount: 32 }, + { priority: ['Richard', 'Bill', 'Warren', 'Steve'], amount: 4 }, + { priority: ['Richard', 'Bill', 'Warren'], amount: 18 }, + { priority: ['Richard', 'Bill'], amount: 58 }, + { priority: ['Richard', 'Elon', 'Bill', 'Steve', 'Warren'], amount: 28 }, + { priority: ['Richard', 'Elon', 'Bill', 'Steve'], amount: 7 }, + { priority: ['Richard', 'Elon', 'Bill', 'Warren', 'Steve'], amount: 33 }, + { priority: ['Richard', 'Elon', 'Bill', 'Warren'], amount: 2 }, + { priority: ['Richard', 'Elon', 'Bill'], amount: 18 }, + { priority: ['Richard', 'Elon', 'Steve', 'Bill', 'Warren'], amount: 36 }, + { priority: ['Richard', 'Elon', 'Steve', 'Bill'], amount: 7 }, + { priority: ['Richard', 'Elon', 'Steve', 'Warren', 'Bill'], amount: 26 }, + { priority: ['Richard', 'Elon', 'Steve', 'Warren'], amount: 3 }, + { priority: ['Richard', 'Elon', 'Steve'], amount: 12 }, + { priority: ['Richard', 'Elon', 'Warren', 'Bill', 'Steve'], amount: 40 }, + { priority: ['Richard', 'Elon', 'Warren', 'Bill'], amount: 4 }, + { priority: ['Richard', 'Elon', 'Warren', 'Steve', 'Bill'], amount: 21 }, + { priority: ['Richard', 'Elon', 'Warren', 'Steve'], amount: 1 }, + { priority: ['Richard', 'Elon', 'Warren'], amount: 18 }, + { priority: ['Richard', 'Elon'], amount: 61 }, + { priority: ['Richard', 'Steve', 'Bill', 'Elon', 'Warren'], amount: 28 }, + { priority: ['Richard', 'Steve', 'Bill', 'Elon'], amount: 3 }, + { priority: ['Richard', 'Steve', 'Bill', 'Warren', 'Elon'], amount: 46 }, + { priority: ['Richard', 'Steve', 'Bill', 'Warren'], amount: 4 }, + { priority: ['Richard', 'Steve', 'Bill'], amount: 22 }, + { priority: ['Richard', 'Steve', 'Elon', 'Bill', 'Warren'], amount: 81 }, + { priority: ['Richard', 'Steve', 'Elon', 'Bill'], amount: 6 }, + { priority: ['Richard', 'Steve', 'Elon', 'Warren', 'Bill'], amount: 39 }, + { priority: ['Richard', 'Steve', 'Elon', 'Warren'], amount: 4 }, + { priority: ['Richard', 'Steve', 'Elon'], amount: 25 }, + { priority: ['Richard', 'Steve', 'Warren', 'Bill', 'Elon'], amount: 38 }, + { priority: ['Richard', 'Steve', 'Warren', 'Bill'], amount: 16 }, + { priority: ['Richard', 'Steve', 'Warren', 'Elon', 'Bill'], amount: 29 }, + { priority: ['Richard', 'Steve', 'Warren', 'Elon'], amount: 6 }, + { priority: ['Richard', 'Steve', 'Warren'], amount: 31 }, + { priority: ['Richard', 'Steve'], amount: 66 }, + { priority: ['Richard', 'Warren', 'Bill', 'Elon', 'Steve'], amount: 28 }, + { priority: ['Richard', 'Warren', 'Bill', 'Elon'], amount: 7 }, + { priority: ['Richard', 'Warren', 'Bill', 'Steve', 'Elon'], amount: 50 }, + { priority: ['Richard', 'Warren', 'Bill', 'Steve'], amount: 6 }, + { priority: ['Richard', 'Warren', 'Bill'], amount: 29 }, + { priority: ['Richard', 'Warren', 'Elon', 'Bill', 'Steve'], amount: 26 }, + { priority: ['Richard', 'Warren', 'Elon', 'Bill'], amount: 3 }, + { priority: ['Richard', 'Warren', 'Elon', 'Steve', 'Bill'], amount: 31 }, + { priority: ['Richard', 'Warren', 'Elon', 'Steve'], amount: 5 }, + { priority: ['Richard', 'Warren', 'Elon'], amount: 23 }, + { priority: ['Richard', 'Warren', 'Steve', 'Bill', 'Elon'], amount: 49 }, + { priority: ['Richard', 'Warren', 'Steve', 'Bill'], amount: 3 }, + { priority: ['Richard', 'Warren', 'Steve', 'Elon', 'Bill'], amount: 25 }, + { priority: ['Richard', 'Warren', 'Steve', 'Elon'], amount: 3 }, + { priority: ['Richard', 'Warren', 'Steve'], amount: 18 }, + { priority: ['Richard', 'Warren'], amount: 62 }, + { priority: ['Richard'], amount: 125 }, + { priority: ['Steve', 'Bill', 'Elon', 'Richard', 'Warren'], amount: 61 }, + { priority: ['Steve', 'Bill', 'Elon', 'Richard'], amount: 2 }, + { priority: ['Steve', 'Bill', 'Elon', 'Warren', 'Richard'], amount: 64 }, + { priority: ['Steve', 'Bill', 'Elon', 'Warren'], amount: 3 }, + { priority: ['Steve', 'Bill', 'Elon'], amount: 46 }, + { priority: ['Steve', 'Bill', 'Richard', 'Elon', 'Warren'], amount: 44 }, + { priority: ['Steve', 'Bill', 'Richard', 'Elon'], amount: 3 }, + { priority: ['Steve', 'Bill', 'Richard', 'Warren', 'Elon'], amount: 64 }, + { priority: ['Steve', 'Bill', 'Richard', 'Warren'], amount: 9 }, + { priority: ['Steve', 'Bill', 'Richard'], amount: 39 }, + { priority: ['Steve', 'Bill', 'Warren', 'Elon', 'Richard'], amount: 59 }, + { priority: ['Steve', 'Bill', 'Warren', 'Elon'], amount: 9 }, + { priority: ['Steve', 'Bill', 'Warren', 'Richard', 'Elon'], amount: 70 }, + { priority: ['Steve', 'Bill', 'Warren', 'Richard'], amount: 8 }, + { priority: ['Steve', 'Bill', 'Warren'], amount: 27 }, + { priority: ['Steve', 'Bill'], amount: 86 }, + { priority: ['Steve', 'Elon', 'Bill', 'Richard', 'Warren'], amount: 40 }, + { priority: ['Steve', 'Elon', 'Bill', 'Richard'], amount: 8 }, + { priority: ['Steve', 'Elon', 'Bill', 'Warren', 'Richard'], amount: 32 }, + { priority: ['Steve', 'Elon', 'Bill', 'Warren'], amount: 5 }, + { priority: ['Steve', 'Elon', 'Bill'], amount: 27 }, + { priority: ['Steve', 'Elon', 'Richard', 'Bill', 'Warren'], amount: 32 }, + { priority: ['Steve', 'Elon', 'Richard', 'Bill'], amount: 4 }, + { priority: ['Steve', 'Elon', 'Richard', 'Warren', 'Bill'], amount: 49 }, + { priority: ['Steve', 'Elon', 'Richard', 'Warren'], amount: 4 }, + { priority: ['Steve', 'Elon', 'Richard'], amount: 27 }, + { priority: ['Steve', 'Elon', 'Warren', 'Bill', 'Richard'], amount: 36 }, + { priority: ['Steve', 'Elon', 'Warren', 'Bill'], amount: 7 }, + { priority: ['Steve', 'Elon', 'Warren', 'Richard', 'Bill'], amount: 39 }, + { priority: ['Steve', 'Elon', 'Warren', 'Richard'], amount: 8 }, + { priority: ['Steve', 'Elon', 'Warren'], amount: 14 }, + { priority: ['Steve', 'Elon'], amount: 94 }, + { priority: ['Steve', 'Richard', 'Bill', 'Elon', 'Warren'], amount: 42 }, + { priority: ['Steve', 'Richard', 'Bill', 'Elon'], amount: 4 }, + { priority: ['Steve', 'Richard', 'Bill', 'Warren', 'Elon'], amount: 59 }, + { priority: ['Steve', 'Richard', 'Bill', 'Warren'], amount: 9 }, + { priority: ['Steve', 'Richard', 'Bill'], amount: 33 }, + { priority: ['Steve', 'Richard', 'Elon', 'Bill', 'Warren'], amount: 53 }, + { priority: ['Steve', 'Richard', 'Elon', 'Bill'], amount: 3 }, + { priority: ['Steve', 'Richard', 'Elon', 'Warren', 'Bill'], amount: 55 }, + { priority: ['Steve', 'Richard', 'Elon', 'Warren'], amount: 4 }, + { priority: ['Steve', 'Richard', 'Elon'], amount: 35 }, + { priority: ['Steve', 'Richard', 'Warren', 'Bill', 'Elon'], amount: 56 }, + { priority: ['Steve', 'Richard', 'Warren', 'Bill'], amount: 9 }, + { priority: ['Steve', 'Richard', 'Warren', 'Elon', 'Bill'], amount: 55 }, + { priority: ['Steve', 'Richard', 'Warren', 'Elon'], amount: 7 }, + { priority: ['Steve', 'Richard', 'Warren'], amount: 37 }, + { priority: ['Steve', 'Richard'], amount: 99 }, + { priority: ['Steve', 'Warren', 'Bill', 'Elon', 'Richard'], amount: 24 }, + { priority: ['Steve', 'Warren', 'Bill', 'Elon'], amount: 7 }, + { priority: ['Steve', 'Warren', 'Bill', 'Richard', 'Elon'], amount: 54 }, + { priority: ['Steve', 'Warren', 'Bill', 'Richard'], amount: 5 }, + { priority: ['Steve', 'Warren', 'Bill'], amount: 32 }, + { priority: ['Steve', 'Warren', 'Elon', 'Bill', 'Richard'], amount: 34 }, + { priority: ['Steve', 'Warren', 'Elon', 'Bill'], amount: 5 }, + { priority: ['Steve', 'Warren', 'Elon', 'Richard', 'Bill'], amount: 31 }, + { priority: ['Steve', 'Warren', 'Elon', 'Richard'], amount: 8 }, + { priority: ['Steve', 'Warren', 'Elon'], amount: 16 }, + { priority: ['Steve', 'Warren', 'Richard', 'Bill', 'Elon'], amount: 36 }, + { priority: ['Steve', 'Warren', 'Richard', 'Bill'], amount: 7 }, + { priority: ['Steve', 'Warren', 'Richard', 'Elon', 'Bill'], amount: 36 }, + { priority: ['Steve', 'Warren', 'Richard', 'Elon'], amount: 9 }, + { priority: ['Steve', 'Warren', 'Richard'], amount: 38 }, + { priority: ['Steve', 'Warren'], amount: 70 }, + { priority: ['Steve'], amount: 154 }, + { priority: ['Warren', 'Bill', 'Elon', 'Richard', 'Steve'], amount: 51 }, + { priority: ['Warren', 'Bill', 'Elon', 'Richard'], amount: 7 }, + { priority: ['Warren', 'Bill', 'Elon', 'Steve', 'Richard'], amount: 40 }, + { priority: ['Warren', 'Bill', 'Elon', 'Steve'], amount: 7 }, + { priority: ['Warren', 'Bill', 'Elon'], amount: 32 }, + { priority: ['Warren', 'Bill', 'Richard', 'Elon', 'Steve'], amount: 37 }, + { priority: ['Warren', 'Bill', 'Richard', 'Elon'], amount: 3 }, + { priority: ['Warren', 'Bill', 'Richard', 'Steve', 'Elon'], amount: 53 }, + { priority: ['Warren', 'Bill', 'Richard', 'Steve'], amount: 4 }, + { priority: ['Warren', 'Bill', 'Richard'], amount: 41 }, + { priority: ['Warren', 'Bill', 'Steve', 'Elon', 'Richard'], amount: 46 }, + { priority: ['Warren', 'Bill', 'Steve', 'Elon'], amount: 6 }, + { priority: ['Warren', 'Bill', 'Steve', 'Richard', 'Elon'], amount: 65 }, + { priority: ['Warren', 'Bill', 'Steve', 'Richard'], amount: 6 }, + { priority: ['Warren', 'Bill', 'Steve'], amount: 40 }, + { priority: ['Warren', 'Bill'], amount: 145 }, + { priority: ['Warren', 'Elon', 'Bill', 'Richard', 'Steve'], amount: 35 }, + { priority: ['Warren', 'Elon', 'Bill', 'Richard'], amount: 5 }, + { priority: ['Warren', 'Elon', 'Bill', 'Steve', 'Richard'], amount: 24 }, + { priority: ['Warren', 'Elon', 'Bill', 'Steve'], amount: 7 }, + { priority: ['Warren', 'Elon', 'Bill'], amount: 23 }, + { priority: ['Warren', 'Elon', 'Richard', 'Bill', 'Steve'], amount: 29 }, + { priority: ['Warren', 'Elon', 'Richard', 'Bill'], amount: 4 }, + { priority: ['Warren', 'Elon', 'Richard', 'Steve', 'Bill'], amount: 33 }, + { priority: ['Warren', 'Elon', 'Richard', 'Steve'], amount: 2 }, + { priority: ['Warren', 'Elon', 'Richard'], amount: 12 }, + { priority: ['Warren', 'Elon', 'Steve', 'Bill', 'Richard'], amount: 34 }, + { priority: ['Warren', 'Elon', 'Steve', 'Bill'], amount: 6 }, + { priority: ['Warren', 'Elon', 'Steve', 'Richard', 'Bill'], amount: 22 }, + { priority: ['Warren', 'Elon', 'Steve', 'Richard'], amount: 4 }, + { priority: ['Warren', 'Elon', 'Steve'], amount: 24 }, + { priority: ['Warren', 'Elon'], amount: 43 }, + { priority: ['Warren', 'Richard', 'Bill', 'Elon', 'Steve'], amount: 27 }, + { priority: ['Warren', 'Richard', 'Bill', 'Elon'], amount: 3 }, + { priority: ['Warren', 'Richard', 'Bill', 'Steve', 'Elon'], amount: 38 }, + { priority: ['Warren', 'Richard', 'Bill', 'Steve'], amount: 3 }, + { priority: ['Warren', 'Richard', 'Bill'], amount: 17 }, + { priority: ['Warren', 'Richard', 'Elon', 'Bill', 'Steve'], amount: 34 }, + { priority: ['Warren', 'Richard', 'Elon', 'Bill'], amount: 5 }, + { priority: ['Warren', 'Richard', 'Elon', 'Steve', 'Bill'], amount: 33 }, + { priority: ['Warren', 'Richard', 'Elon', 'Steve'], amount: 4 }, + { priority: ['Warren', 'Richard', 'Elon'], amount: 21 }, + { priority: ['Warren', 'Richard', 'Steve', 'Bill', 'Elon'], amount: 32 }, + { priority: ['Warren', 'Richard', 'Steve', 'Bill'], amount: 5 }, + { priority: ['Warren', 'Richard', 'Steve', 'Elon', 'Bill'], amount: 27 }, + { priority: ['Warren', 'Richard', 'Steve', 'Elon'], amount: 4 }, + { priority: ['Warren', 'Richard', 'Steve'], amount: 21 }, + { priority: ['Warren', 'Richard'], amount: 63 }, + { priority: ['Warren', 'Steve', 'Bill', 'Elon', 'Richard'], amount: 43 }, + { priority: ['Warren', 'Steve', 'Bill', 'Elon'], amount: 4 }, + { priority: ['Warren', 'Steve', 'Bill', 'Richard', 'Elon'], amount: 50 }, + { priority: ['Warren', 'Steve', 'Bill', 'Richard'], amount: 6 }, + { priority: ['Warren', 'Steve', 'Bill'], amount: 21 }, + { priority: ['Warren', 'Steve', 'Elon', 'Bill', 'Richard'], amount: 30 }, + { priority: ['Warren', 'Steve', 'Elon', 'Bill'], amount: 4 }, + { priority: ['Warren', 'Steve', 'Elon', 'Richard', 'Bill'], amount: 33 }, + { priority: ['Warren', 'Steve', 'Elon', 'Richard'], amount: 2 }, + { priority: ['Warren', 'Steve', 'Elon'], amount: 21 }, + { priority: ['Warren', 'Steve', 'Richard', 'Bill', 'Elon'], amount: 37 }, + { priority: ['Warren', 'Steve', 'Richard', 'Bill'], amount: 9 }, + { priority: ['Warren', 'Steve', 'Richard', 'Elon', 'Bill'], amount: 29 }, + { priority: ['Warren', 'Steve', 'Richard', 'Elon'], amount: 4 }, + { priority: ['Warren', 'Steve', 'Richard'], amount: 21 }, + { priority: ['Warren', 'Steve'], amount: 61 }, + { priority: ['Warren'], amount: 155 }, + ], +}; diff --git a/test/stv/datasets/index.js b/test/stv/datasets/index.js new file mode 100644 index 00000000..e89d8601 --- /dev/null +++ b/test/stv/datasets/index.js @@ -0,0 +1,16 @@ +module.exports = { + datasetOpaVote: require('./datasetOpaVote'), + dataset1: require('./dataset1'), + dataset2: require('./dataset2'), + dataset3: require('./dataset3'), + dataset4: require('./dataset4'), + dataset5: require('./dataset5'), + dataset6: require('./dataset6'), + dataset7: require('./dataset7'), + dataset8: require('./dataset8'), + dataset9: require('./dataset9'), + dataset10: require('./dataset10'), + dataset11: require('./dataset11'), + dataset12: require('./dataset12'), + dataset13: require('./dataset13'), +}; diff --git a/test/stv/float.test.js b/test/stv/float.test.js new file mode 100644 index 00000000..7b49b087 --- /dev/null +++ b/test/stv/float.test.js @@ -0,0 +1,227 @@ +const chai = require('chai'); +const chaiSubset = require('chai-subset'); +const dataset = require('./datasets'); +const { prepareElection } = require('../helpers'); + +chai.use(chaiSubset); + +describe('STV Floating Point Logic', () => { + it('should calculate floating points correctly for dataset5', async function () { + const election = await prepareElection(dataset.dataset5); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 8, + seats: 2, + voteCount: 23, + useStrict: false, + result: { + status: 'RESOLVED', + winners: [{ description: 'A' }, { description: 'B' }], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + A: 9, + B: 7, + C: 4, + D: 3, + }, + }, + { + action: 'WIN', + alternative: { description: 'A' }, + voteCount: 9, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [{ description: 'A' }], + counts: { + B: 7.3333, + C: 4.3333, + D: 3.3333, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'D' }, + minScore: 3.3333, + }, + { + action: 'ITERATION', + iteration: 3, + winners: [{ description: 'A' }], + counts: { + B: 7.6667, + C: 4.3333, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'C' }, + minScore: 4.3333, + }, + { + action: 'ITERATION', + iteration: 4, + winners: [{ description: 'A' }], + counts: { + B: 8, + }, + }, + { + action: 'WIN', + alternative: { description: 'B' }, + voteCount: 8, + }, + ], + }); + }); + + it('should calculate floating points correctly for dataset6', async function () { + const election = await prepareElection(dataset.dataset6); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 71, + seats: 2, + voteCount: 210, + useStrict: false, + result: { + status: 'UNRESOLVED', + winners: [{ description: 'A' }], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + A: 90, + B: 40, + C: 40, + D: 40, + }, + }, + { + action: 'WIN', + alternative: { description: 'A' }, + voteCount: 90, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [{ description: 'A' }], + counts: { + B: 46.3333, + C: 46.3333, + D: 40, + E: 3.5889, + F: 2.7444, + }, + }, + { + action: 'TIE', + description: + 'There are 2 candidates with a score of 0 at iteration 2', + }, + { + action: 'TIE', + description: + 'The backward checking went to iteration 1 without breaking the tie', + }, + { + action: 'MULTI_TIE_ELIMINATIONS', + alternatives: [{ description: 'G' }, { description: 'H' }], + minScore: 0, + }, + { + action: 'ITERATION', + iteration: 3, + winners: [{ description: 'A' }], + counts: { + B: 46.3333, + C: 46.3333, + D: 40, + E: 3.5889, + F: 2.7444, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'F' }, + minScore: 2.7444, + }, + { + action: 'ITERATION', + iteration: 4, + winners: [{ description: 'A' }], + counts: { + B: 46.3333, + C: 46.3333, + D: 42.7444, + E: 3.5889, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'E' }, + minScore: 3.5889, + }, + { + action: 'ITERATION', + iteration: 5, + winners: [{ description: 'A' }], + counts: { + B: 46.3333, + C: 46.3333, + D: 46.3333, + }, + }, + { + action: 'TIE', + description: + 'There are 3 candidates with a score of 46.3333 at iteration 5', + }, + // Egde case iteration. See the dataset for explanation + { + action: 'ELIMINATE', + alternative: { description: 'D' }, + minScore: 42.7444, + }, + { + action: 'ITERATION', + iteration: 6, + winners: [{ description: 'A' }], + counts: { + B: 46.3333, + C: 46.3333, + }, + }, + { + action: 'TIE', + description: + 'There are 2 candidates with a score of 46.3333 at iteration 6', + }, + { + action: 'TIE', + description: + 'The backward checking went to iteration 1 without breaking the tie', + }, + { + action: 'MULTI_TIE_ELIMINATIONS', + alternatives: [{ description: 'B' }, { description: 'C' }], + minScore: 46.3333, + }, + { + action: 'ITERATION', + iteration: 7, + winners: [{ description: 'A' }], + counts: {}, + }, + ], + }); + }); +}); diff --git a/test/stv/normal.test.js b/test/stv/normal.test.js new file mode 100644 index 00000000..d87a754a --- /dev/null +++ b/test/stv/normal.test.js @@ -0,0 +1,322 @@ +const chai = require('chai'); +const chaiSubset = require('chai-subset'); +const dataset = require('./datasets'); +const { prepareElection } = require('../helpers'); + +chai.use(chaiSubset); + +describe('STV Normal Logic', () => { + it('should find 2 winners, and resolve for dataset 1', async function () { + const election = await prepareElection(dataset.dataset1); + const electionResult = await election.elect(); + + electionResult.should.containSubset({ + thr: 34, + seats: 2, + voteCount: 100, + useStrict: false, + result: { + status: 'RESOLVED', + winners: [{ description: 'Andrea' }, { description: 'Carter' }], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + Andrea: 45, + Brad: 30, + Carter: 25, + }, + }, + { + action: 'WIN', + alternative: { description: 'Andrea' }, + voteCount: 45, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [{ description: 'Andrea' }], + counts: { + Brad: 30, + Carter: 36, + }, + }, + { + action: 'WIN', + alternative: { description: 'Carter' }, + voteCount: 36, + }, + ], + }); + }); + + it('should find 2 winners, but not resolve for dataset 2', async function () { + const election = await prepareElection(dataset.dataset2); + const electionResult = await election.elect(); + + electionResult.should.containSubset({ + thr: 6, + seats: 3, + voteCount: 20, + useStrict: false, + result: { + status: 'UNRESOLVED', + winners: [{ description: 'Chocolate' }, { description: 'Orange' }], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + Orange: 4, + Pear: 2, + Chocolate: 12, + Strawberry: 1, + Hamburger: 1, + }, + }, + { + action: 'WIN', + alternative: { description: 'Chocolate' }, + voteCount: 12, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [{ description: 'Chocolate' }], + counts: { + Orange: 4, + Pear: 2, + Strawberry: 5, + Hamburger: 3, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'Pear' }, + minScore: 2, + }, + { + action: 'ITERATION', + iteration: 3, + winners: [{ description: 'Chocolate' }], + counts: { + Orange: 6, + Strawberry: 5, + Hamburger: 3, + }, + }, + { + action: 'WIN', + alternative: { description: 'Orange' }, + voteCount: 6, + }, + { + action: 'ITERATION', + iteration: 4, + winners: [{ description: 'Chocolate' }, { description: 'Orange' }], + counts: { + Strawberry: 5, + Hamburger: 3, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'Hamburger' }, + minScore: 3, + }, + { + action: 'ITERATION', + iteration: 5, + counts: { + Strawberry: 5, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'Strawberry' }, + minScore: 5, + }, + { + action: 'ITERATION', + iteration: 6, + counts: {}, + }, + ], + }); + }); + + it('should find 4 winners, but not resolve for dataset 3', async function () { + const election = await prepareElection(dataset.dataset3); + const electionResult = await election.elect(); + + electionResult.should.containSubset({ + thr: 108, + seats: 5, + voteCount: 647, + useStrict: false, + result: { + status: 'UNRESOLVED', + winners: [ + { description: 'EVANS' }, + { description: 'STEWART' }, + { description: 'AUGUSTINE' }, + { description: 'HARLEY' }, + ], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + STEWART: 66, + VINE: 48, + AUGUSTINE: 95, + COHEN: 55, + LENNON: 58, + EVANS: 144, + WILCOCKS: 60, + HARLEY: 91, + PEARSON: 30, + }, + }, + { + action: 'WIN', + alternative: { description: 'EVANS' }, + voteCount: 144, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [{ description: 'EVANS' }], + counts: { + STEWART: 68, + VINE: 68, + AUGUSTINE: 95, + COHEN: 64, + LENNON: 58, + WILCOCKS: 60, + HARLEY: 92, + PEARSON: 34, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'PEARSON' }, + minScore: 34, + }, + { + action: 'ITERATION', + iteration: 3, + winners: [{ description: 'EVANS' }], + counts: { + STEWART: 69, + VINE: 91, + AUGUSTINE: 96, + COHEN: 69, + LENNON: 58, + WILCOCKS: 60, + HARLEY: 93, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'LENNON' }, + minScore: 58, + }, + { + action: 'ITERATION', + iteration: 4, + winners: [{ description: 'EVANS' }], + counts: { + STEWART: 115, + VINE: 97, + AUGUSTINE: 96, + COHEN: 71, + WILCOCKS: 60, + HARLEY: 93, + }, + }, + { + action: 'WIN', + alternative: { description: 'STEWART' }, + voteCount: 115, + }, + { + action: 'ITERATION', + iteration: 5, + winners: [{ description: 'EVANS' }, { description: 'STEWART' }], + counts: { + VINE: 97, + AUGUSTINE: 103, + COHEN: 71, + WILCOCKS: 60, + HARLEY: 93, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'WILCOCKS' }, + minScore: 60, + }, + { + action: 'ITERATION', + iteration: 6, + winners: [{ description: 'EVANS' }, { description: 'STEWART' }], + counts: { + VINE: 104, + AUGUSTINE: 135, + COHEN: 72, + HARLEY: 108, + }, + }, + { + action: 'WIN', + alternative: { description: 'AUGUSTINE' }, + voteCount: 135, + }, + { + action: 'WIN', + alternative: { description: 'HARLEY' }, + voteCount: 108, + }, + { + action: 'ITERATION', + iteration: 7, + winners: [ + { description: 'EVANS' }, + { description: 'STEWART' }, + { description: 'AUGUSTINE' }, + { description: 'HARLEY' }, + ], + counts: { + VINE: 104, + COHEN: 72, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'COHEN' }, + minScore: 72, + }, + { + action: 'ITERATION', + iteration: 8, + counts: { + VINE: 104, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'VINE' }, + minScore: 104, + }, + ], + }); + }); +}); diff --git a/test/stv/opaVote.test.js b/test/stv/opaVote.test.js new file mode 100644 index 00000000..d20177e5 --- /dev/null +++ b/test/stv/opaVote.test.js @@ -0,0 +1,92 @@ +const chai = require('chai'); +const chaiSubset = require('chai-subset'); +const dataset = require('./datasets'); +const { prepareElection } = require('../helpers'); + +chai.use(chaiSubset); + +describe('STV OpaVote', () => { + it('should calculate the result correctly for the OpaVote dataset', async function () { + const election = await prepareElection(dataset.datasetOpaVote); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 3750, + seats: 2, + voteCount: 11248, + useStrict: false, + result: { + status: 'RESOLVED', + winners: [{ description: 'Steve' }, { description: 'Bill' }], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + Steve: 2146, + Elon: 1926, + Bill: 2219, + Warren: 1757, + Richard: 1586, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'Richard' }, + minScore: 1586, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [], + counts: { + Steve: 2590, + Elon: 2243, + Bill: 2551, + Warren: 2125, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'Warren' }, + minScore: 2125, + }, + { + action: 'ITERATION', + iteration: 3, + winners: [], + counts: { + Steve: 3152, + Elon: 2735, + Bill: 3342, + }, + }, + { + action: 'ELIMINATE', + alternative: { description: 'Elon' }, + minScore: 2735, + }, + { + action: 'ITERATION', + iteration: 4, + winners: [], + counts: { + Steve: 4259, + Bill: 4502, + }, + }, + { + action: 'WIN', + alternative: { description: 'Steve' }, + voteCount: 4259, + }, + { + action: 'WIN', + alternative: { description: 'Bill' }, + voteCount: 4502, + }, + ], + }); + }); +}); diff --git a/test/stv/strict.test.js b/test/stv/strict.test.js new file mode 100644 index 00000000..d233a798 --- /dev/null +++ b/test/stv/strict.test.js @@ -0,0 +1,65 @@ +const chai = require('chai'); +const chaiSubset = require('chai-subset'); +const dataset = require('./datasets'); +const { prepareElection } = require('../helpers'); + +chai.use(chaiSubset); + +describe('STV Strict Logic', () => { + it('should not resolve for the strict election in dataset 7', async function () { + const election = await prepareElection(dataset.dataset7); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 14, + seats: 1, + voteCount: 20, + useStrict: true, + result: { + status: 'UNRESOLVED', + winners: [], + }, + }); + }); + it('should not resolve for the strict election in dataset 8', async function () { + const election = await prepareElection(dataset.dataset8); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 15, + seats: 1, + voteCount: 21, + useStrict: true, + result: { + status: 'UNRESOLVED', + winners: [], + }, + }); + }); + it('should not resolve for the strict election in dataset 9', async function () { + const election = await prepareElection(dataset.dataset9); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 21, + seats: 1, + voteCount: 30, + useStrict: true, + result: { + status: 'UNRESOLVED', + winners: [], + }, + }); + }); + it('should resolve for the strict election in dataset 10', async function () { + const election = await prepareElection(dataset.dataset10); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 21, + seats: 1, + voteCount: 31, + useStrict: true, + result: { + status: 'RESOLVED', + winners: [{ description: 'A' }], + }, + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..0594a0db --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "sourceMap": true, + "target": "es2015", + "outDir": "app/stv", + "module": "commonjs", + "removeComments": true + }, + "include": [ + "app/**/*" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/yarn.lock b/yarn.lock index f008e111..84727870 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9,29 +9,7 @@ dependencies: "@babel/highlight" "^7.10.4" -"@babel/core@^7.1.6": - version "7.12.3" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.3.tgz#1b436884e1e3bff6fb1328dc02b208759de92ad8" - integrity sha512-0qXcZYKZp3/6N2jKYVxZv0aNCsxTSVCiK72DTiTYZAu7sjg73W0/aynWjMbiGd87EQL4WyA8reiJVh92AVla9g== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.1" - "@babel/helper-module-transforms" "^7.12.1" - "@babel/helpers" "^7.12.1" - "@babel/parser" "^7.12.3" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.12.1" - "@babel/types" "^7.12.1" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.1" - json5 "^2.1.2" - lodash "^4.17.19" - resolve "^1.3.2" - semver "^5.4.1" - source-map "^0.5.0" - -"@babel/core@^7.7.5": +"@babel/core@^7.1.6", "@babel/core@^7.7.5": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.10.tgz#b79a2e1b9f70ed3d84bbfb6d8c4ef825f606bccd" integrity sha512-eTAlQKq65zHfkHZV0sIVODCPGVgoo1HdBlbSLi9CqOzuZanMv2ihzY+4paiKr1mH+XmYESMAmJ/dpZ68eN6d8w== @@ -52,15 +30,6 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.12.1", "@babel/generator@^7.12.5": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.5.tgz#a2c50de5c8b6d708ab95be5e6053936c1884a4de" - integrity sha512-m16TQQJ8hPt7E+OS/XVQg/7U184MLXtvuGbCdA7na61vha+ImkyyNM/9DDA0unYCVZn3ZOhng+qz48/KBOT96A== - dependencies: - "@babel/types" "^7.12.5" - jsesc "^2.5.1" - source-map "^0.5.0" - "@babel/generator@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.10.tgz#2b188fc329fb8e4f762181703beffc0fe6df3460" @@ -91,18 +60,18 @@ "@babel/types" "^7.10.4" "@babel/helper-get-function-arity@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" - integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" + integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-member-expression-to-functions@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.1.tgz#fba0f2fcff3fba00e6ecb664bb5e6e26e2d6165c" - integrity sha512-k0CIe3tXUKTRSoEx1LQEPFU9vRQfqHtl+kf8eNnDqb4AUJEy5pz6aIiog+YWtVm2jpggjS1laH68bPsR+KWWPQ== + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" + integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw== dependencies: - "@babel/types" "^7.12.1" + "@babel/types" "^7.12.7" "@babel/helper-module-imports@^7.12.1": version "7.12.5" @@ -127,11 +96,11 @@ lodash "^4.17.19" "@babel/helper-optimise-call-expression@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" - integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d" + integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0": version "7.10.4" @@ -174,7 +143,12 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== -"@babel/helpers@^7.12.1", "@babel/helpers@^7.12.5": +"@babel/helper-validator-option@^7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.1.tgz#175567380c3e77d60ff98a54bb015fe78f2178d9" + integrity sha512-YpJabsXlJVWP0USHjnC/AQDTLlZERbON577YUVO/wLpqyj6HAtVYnWaQaN0iUN+1/tWn3c+uKKXjRut5115Y2A== + +"@babel/helpers@^7.12.5": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA== @@ -192,17 +166,7 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.6", "@babel/parser@^7.12.3", "@babel/parser@^7.12.5": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.5.tgz#b4af32ddd473c0bfa643bd7ff0728b8e71b81ea0" - integrity sha512-FVM6RZQ0mn2KCf1VUED7KepYeUWoVShczewOCfm3nzoBybaih51h+sYVVGthW9M6lPByEPTQf+xm27PBdlpwmQ== - -"@babel/parser@^7.10.4": - version "7.11.5" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" - integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== - -"@babel/parser@^7.12.10", "@babel/parser@^7.12.7": +"@babel/parser@^7.1.6", "@babel/parser@^7.12.10", "@babel/parser@^7.12.7", "@babel/parser@^7.7.0": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.10.tgz#824600d59e96aea26a5a2af5a9d812af05c3ae81" integrity sha512-PJdRPwyoOqFAWfLytxrWwGrAxghCgh/yTNCYciOz8QgjflA7aZhECPZAa2VUedKg2+QMWkI0L9lynh2SNmNEgA== @@ -224,9 +188,9 @@ "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" "@babel/plugin-proposal-optional-chaining@^7.1.0": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.1.tgz#cce122203fc8a32794296fc377c6dedaf4363797" - integrity sha512-c2uRpY6WzaVDzynVY9liyykS+kVU+WRZPMPYpkelXH8KBt1oXoI89kPbZKKG/jDT5UK92FTW2fZkZaJhdiBabw== + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.7.tgz#e02f0ea1b5dc59d401ec16fb824679f683d3303c" + integrity sha512-4ovylXZ0PWmwoOvhU2vhnzVNnm88/Sm9nx7V8BPgMvAzn5zDou3/Awy0EjglyubVHasJj+XCEkr/r1X3P5elCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" @@ -261,9 +225,9 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-flow-strip-types@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.12.1.tgz#8430decfa7eb2aea5414ed4a3fa6e1652b7d77c4" - integrity sha512-8hAtkmsQb36yMmEtk2JZ9JnVyDSnDOdlB+0nEGzIDLuK4yR3JcEjfuFPYkdEPSh8Id+rAMeBEn+X0iVEyho6Hg== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.12.10.tgz#d85e30ecfa68093825773b7b857e5085bbd32c95" + integrity sha512-0ti12wLTLeUIzu9U7kjqIn4MyOL7+Wibc7avsHhj4o1l5C0ATs8p2IMHrVYjm9t9wzhfEO6S3kxax0Rpdo8LTg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-flow" "^7.12.1" @@ -296,17 +260,18 @@ "@babel/plugin-transform-flow-strip-types" "^7.12.1" "@babel/preset-typescript@^7.1.0": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.12.1.tgz#86480b483bb97f75036e8864fe404cc782cc311b" - integrity sha512-hNK/DhmoJPsksdHuI/RVrcEws7GN5eamhi28JkO52MqIxU8Z0QpmiSOQxZHWOHV7I3P4UjHV97ay4TcamMA6Kw== + version "7.12.7" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.12.7.tgz#fc7df8199d6aae747896f1e6c61fc872056632a3" + integrity sha512-nOoIqIqBmHBSEgBXWR4Dv/XBehtIFcw9PqZw6rFYuKrzsZmOQm3PR5siLBnKZFEsDb03IegG8nSjU/iXXXYRmw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-validator-option" "^7.12.1" "@babel/plugin-transform-typescript" "^7.12.1" "@babel/register@^7.0.0": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.12.1.tgz#cdb087bdfc4f7241c03231f22e15d211acf21438" - integrity sha512-XWcmseMIncOjoydKZnWvWi0/5CUCD+ZYKhRwgYlWOrA8fGZ/FjuLRpqtIhLOVD/fvR1b9DQHtZPn68VvhpYf+Q== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.12.10.tgz#19b87143f17128af4dbe7af54c735663b3999f60" + integrity sha512-EvX/BvMMJRAA3jZgILWgbsrHwBQvllC5T8B29McyME8DvkdOxk4ujESfrMvME8IHSDvWXrmMXxPvA/lx2gqPLQ== dependencies: find-cache-dir "^2.0.0" lodash "^4.17.19" @@ -314,16 +279,7 @@ pirates "^4.0.0" source-map-support "^0.5.16" -"@babel/template@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" - integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/parser" "^7.10.4" - "@babel/types" "^7.10.4" - -"@babel/template@^7.12.7": +"@babel/template@^7.10.4", "@babel/template@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" integrity sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow== @@ -332,22 +288,7 @@ "@babel/parser" "^7.12.7" "@babel/types" "^7.12.7" -"@babel/traverse@^7.12.1", "@babel/traverse@^7.12.5": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.5.tgz#78a0c68c8e8a35e4cacfd31db8bb303d5606f095" - integrity sha512-xa15FbQnias7z9a62LwYAA5SZZPkHIXpd42C6uW68o8uTuua96FHZy1y61Va5P/i83FAAcMpW8+A/QayntzuqA== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.5" - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/parser" "^7.12.5" - "@babel/types" "^7.12.5" - debug "^4.1.0" - globals "^11.1.0" - lodash "^4.17.19" - -"@babel/traverse@^7.12.10": +"@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5", "@babel/traverse@^7.7.0": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.10.tgz#2d1f4041e8bf42ea099e5b2dc48d6a594c00017a" integrity sha512-6aEtf0IeRgbYWzta29lePeYSk+YAFIC3kyqESeft8o5CkFlYIMX+EQDDWEiAQ9LHOA3d0oHdgrSsID/CKqXJlg== @@ -362,25 +303,7 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/types@^7.10.4", "@babel/types@^7.11.0": - version "7.11.5" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" - integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q== - dependencies: - "@babel/helper-validator-identifier" "^7.10.4" - lodash "^4.17.19" - to-fast-properties "^2.0.0" - -"@babel/types@^7.12.1", "@babel/types@^7.12.5": - version "7.12.6" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.6.tgz#ae0e55ef1cce1fbc881cd26f8234eb3e657edc96" - integrity sha512-hwyjw6GvjBLiyy3W0YQf0Z5Zf4NpYejUnKFcfcUhZCSffoBBp30w6wP2Wn6pk31jMYZvcOrB/1b7cGXvEoKogA== - dependencies: - "@babel/helper-validator-identifier" "^7.10.4" - lodash "^4.17.19" - to-fast-properties "^2.0.0" - -"@babel/types@^7.12.10", "@babel/types@^7.12.7": +"@babel/types@^7.10.4", "@babel/types@^7.11.0", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.7.0": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.10.tgz#7965e4a7260b26f09c56bcfcb0498af1f6d9b260" integrity sha512-sf6wboJV5mGyip2hIpDSKsr80RszPinEFjsHTalMxZAZkoQ2/2yQzxlcFN52SJqsyPfLtPmenL4g2KB3KJXPDw== @@ -508,15 +431,20 @@ dependencies: "@types/node" "*" +"@types/lodash@4.14.167": + version "4.14.167" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.167.tgz#ce7d78553e3c886d4ea643c37ec7edc20f16765e" + integrity sha512-w7tQPjARrvdeBkX/Rwg95S592JwxqOjmms3zWQ0XZgSyxSLdzWaYH3vErBhdVS/lRBX7F8aBYcYJYTr5TMGOzw== + "@types/minimatch@*", "@types/minimatch@^3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== "@types/node@*": - version "14.14.7" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.7.tgz#8ea1e8f8eae2430cf440564b98c6dfce1ec5945d" - integrity sha512-Zw1vhUSQZYw+7u5dAwNbIA9TuTotpzY/OF7sJM9FqPOF3SPjKnxrjoTktXDZgUjybf4cWVBP7O8wvKdSaGHweg== + version "14.14.14" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.14.tgz#f7fd5f3cc8521301119f63910f0fb965c7d761ae" + integrity sha512-UHnOPWVWV1z+VV8k6L1HhG7UbGBgIdghqF3l9Ny9ApPghbjICXkUJSd/b9gOgQfjM1r+37cipdw/HJ3F6ICEnQ== "@types/normalize-package-data@^2.4.0": version "2.4.0" @@ -799,9 +727,9 @@ acorn@^5.0.0, acorn@^5.6.2: integrity sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg== acorn@^6.0.7: - version "6.4.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" - integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== + version "6.4.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" + integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ== acorn@^7.1.1: version "7.4.1" @@ -843,17 +771,7 @@ ajv-keywords@^3.1.0: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.1.0, ajv@^6.10.2, ajv@^6.9.1: - version "6.12.4" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" - integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@^6.12.3: +ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.9.1: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -968,6 +886,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +any-base@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/any-base/-/any-base-1.1.0.tgz#ae101a62bc08a597b4c9ab5b7089d456630549fe" + integrity sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg== + anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" @@ -1197,6 +1120,18 @@ babel-core@^7.0.0-bridge.0: resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece" integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg== +babel-eslint@10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232" + integrity sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.7.0" + "@babel/traverse" "^7.7.0" + "@babel/types" "^7.7.0" + eslint-visitor-keys "^1.0.0" + resolve "^1.12.0" + babel-plugin-dynamic-import-node@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3" @@ -1243,9 +1178,9 @@ base64-arraybuffer@0.1.5: integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg= base64-js@^1.0.2: - version "1.3.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" - integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== base64id@1.0.0: version "1.0.0" @@ -1311,7 +1246,7 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bl@^2.2.0: +bl@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.1.tgz#8c11a7b730655c5d56898cdc871224f40fd901d5" integrity sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g== @@ -1351,7 +1286,7 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== -bn.js@^5.1.1: +bn.js@^5.0.0, bn.js@^5.1.1: version "5.1.3" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b" integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ== @@ -1445,11 +1380,11 @@ browserify-des@^1.0.0: safe-buffer "^5.1.2" browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" - integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ= + version "4.1.0" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" + integrity sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== dependencies: - bn.js "^4.1.0" + bn.js "^5.0.0" randombytes "^2.0.1" browserify-sign@^4.0.0: @@ -1475,9 +1410,9 @@ browserify-zlib@^0.2.0: pako "~1.0.5" browserstack@^1.5.1: - version "1.6.0" - resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.6.0.tgz#5a56ab90987605d9c138d7a8b88128370297f9bf" - integrity sha512-HJDJ0TSlmkwnt9RZ+v5gFpa1XZTBYTj0ywvLwJ3241J7vMw2jAsGNVhKHtmCOyg+VxeLZyaibO9UL71AsUeDIw== + version "1.6.1" + resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.6.1.tgz#e051f9733ec3b507659f395c7a4765a1b1e358b3" + integrity sha512-GxtFjpIaKdbAyzHfFDKixKO8IBT7wR3NjbzrGc78nNs/Ciys9wU3/nBtsqsWv5nDSrdI5tz0peKuzCPuNXNUiw== dependencies: https-proxy-agent "^2.2.1" @@ -1552,9 +1487,9 @@ cache-base@^1.0.1: unset-value "^1.0.0" cacheable-lookup@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.3.tgz#049fdc59dffdd4fc285e8f4f82936591bd59fec3" - integrity sha512-W+JBqF9SWe18A72XFzN/V/CULFzPm7sBXzzR6ekkE+3tLG72wFZrBiBZhrZuDoYexop4PHJVdFAKb/Nj9+tm9w== + version "5.0.4" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" + integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== cacheable-request@^7.0.1: version "7.0.1" @@ -1650,6 +1585,11 @@ chai-as-promised@7.1.1: dependencies: check-error "^1.0.2" +chai-subset@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/chai-subset/-/chai-subset-1.6.0.tgz#a5d0ca14e329a79596ed70058b6646bd6988cfe9" + integrity sha1-pdDKFOMpp5WW7XAFi2ZGvWmIz+k= + chai@4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/chai/-/chai-4.2.0.tgz#760aa72cf20e3795e84b12877ce0e83737aa29e5" @@ -1732,9 +1672,9 @@ chokidar@^2.1.8: fsevents "^1.2.7" chokidar@^3.4.1: - version "3.4.2" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" - integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== + version "3.4.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.3.tgz#c1df38231448e45ca4ac588e6c79573ba6a57d5b" + integrity sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ== dependencies: anymatch "~3.1.1" braces "~3.0.2" @@ -1742,7 +1682,7 @@ chokidar@^3.4.1: is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" - readdirp "~3.4.0" + readdirp "~3.5.0" optionalDependencies: fsevents "~2.1.2" @@ -1803,11 +1743,12 @@ cli-cursor@^3.1.0: restore-cursor "^3.1.0" cli-table@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23" - integrity sha1-9TsFJmqLGguTSz0IIebi3FkUriM= + version "0.3.4" + resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.4.tgz#5b37fd723751f1a6e9e70d55953a75e16eab958e" + integrity sha512-1vinpnX/ZERcmE443i3SZTmU5DF0rPO9DrL4I2iVAllhxzCM9SzPlHnz19fsZB78htkKZvYBvj6SZ6vXnaxmTA== dependencies: - colors "1.0.3" + chalk "^2.4.1" + string-width "^4.2.0" cli-width@^2.0.0: version "2.2.1" @@ -1942,11 +1883,6 @@ colorette@^1.2.1: resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== -colors@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" - integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= - colors@^1.1.2: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" @@ -1980,9 +1916,9 @@ commander@^2.20.0, commander@^2.9.0: integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== commander@^6.0.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75" - integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== commondir@^1.0.1: version "1.0.1" @@ -1999,7 +1935,7 @@ component-emitter@1.2.1: resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= -component-emitter@^1.2.0, component-emitter@^1.2.1: +component-emitter@^1.2.0, component-emitter@^1.2.1, component-emitter@~1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== @@ -2114,9 +2050,9 @@ copy-descriptor@^0.1.0: integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= core-js@^2.4.0: - version "2.6.11" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" - integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== + version "2.6.12" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" + integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -2323,12 +2259,12 @@ dateformat@^3.0.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -debug@*, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@~4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== +debug@*, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== dependencies: - ms "^2.1.1" + ms "2.1.2" debug@2.6.9, debug@^2.2.0, debug@^2.3.3: version "2.6.9" @@ -2344,13 +2280,27 @@ debug@3.1.0, debug@=3.1.0, debug@~3.1.0: dependencies: ms "2.0.0" -debug@3.2.6, debug@^3.1.0: +debug@3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== dependencies: ms "^2.1.1" +debug@^3.1.0: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@~4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -2720,23 +2670,6 @@ error@^7.0.2: dependencies: string-template "~0.2.1" -es-abstract@^1.17.0-next.1: - version "1.17.7" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" - integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== - dependencies: - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.1" - is-callable "^1.2.2" - is-regex "^1.1.1" - object-inspect "^1.8.0" - object-keys "^1.1.1" - object.assign "^4.1.1" - string.prototype.trimend "^1.0.1" - string.prototype.trimstart "^1.0.1" - es-abstract@^1.18.0-next.1: version "1.18.0-next.1" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" @@ -3282,9 +3215,9 @@ flatted@^2.0.0: integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== flow-parser@0.*: - version "0.138.0" - resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.138.0.tgz#2d9818f6b804d66f90949dfa8b4892f3a0af546d" - integrity sha512-LFnTyjrv39UvCWl8NOcpByr/amj8a5k5z7isO2wv4T43nNrUnHQwX3rarTz9zcpHXkDAQv6X4MfQ4ZzJUptpbw== + version "0.140.0" + resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.140.0.tgz#f737901bf8343c843417cac695b0b428a54843c6" + integrity sha512-z57YJZXcO0mmlNoOf9uvdnoZXanu8ALTqSaAWAv6kQavpnA5Kpdd4R7B3wP56+/yi/yODjrtarQYV/bgv867Iw== flush-write-stream@^1.0.0: version "1.1.1" @@ -3625,9 +3558,9 @@ globby@^9.2.0: slash "^2.0.0" got@^11.8.0: - version "11.8.0" - resolved "https://registry.yarnpkg.com/got/-/got-11.8.0.tgz#be0920c3586b07fd94add3b5b27cb28f49e6545f" - integrity sha512-k9noyoIIY9EejuhaBNLyZ31D5328LeqnyPNXJQb2XlJZcKakLqN5m6O/ikhq/0lw56kUYS54fVm+D1x57YC9oQ== + version "11.8.1" + resolved "https://registry.yarnpkg.com/got/-/got-11.8.1.tgz#df04adfaf2e782babb3daabc79139feec2f7e85d" + integrity sha512-9aYdZL+6nHmvJwHALLwKSUZ0hMwGaJGYv3hoPLPgnT8BoBXm1SjnZeky+91tfwJaDzun2s4RsBRy48IEYv2q2Q== dependencies: "@sindresorhus/is" "^4.0.0" "@szmarczak/http-timer" "^4.0.5" @@ -3675,6 +3608,18 @@ growl@1.10.5: resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== +handlebars@4.7.6: + version "4.7.6" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.6.tgz#d4c05c1baf90e9945f77aa68a7a219aa4a7df74e" + integrity sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.0" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" @@ -3902,9 +3847,9 @@ icss-utils@^4.0.0: postcss "^7.0.14" ieee754@^1.1.4: - version "1.1.13" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" - integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== iferr@^0.1.5: version "0.1.5" @@ -3927,9 +3872,9 @@ immediate@~3.0.5: integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= import-fresh@^3.0.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66" - integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ== + version "3.2.2" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.2.tgz#fc129c160c5d68235507f4331a6baad186bdbc3e" + integrity sha512-cTPNrlvJT6twpYy+YmKUKrTSjWFs3bjYjAhCwm+z4EOCubZxAuO+hHpRN64TqjEaYSHs7tJAE0w1CKMGmsG/lw== dependencies: parent-module "^1.0.0" resolve-from "^4.0.0" @@ -3991,9 +3936,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.0, ini@^1.3.4, ini@^1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== inquirer@^6.2.2: version "6.5.2" @@ -4102,9 +4047,9 @@ is-callable@^1.1.4, is-callable@^1.2.2: integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== is-core-module@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.1.0.tgz#a4cc031d9b1aca63eecbd18a650e13cb4eeab946" - integrity sha512-YcV7BgVMRFRua2FqQzKtTDMz8iCuLEyGKjr70q8Zm1yy2qKcurbFEd79PAdHV77oL3NrAaOVQIbMmiHQCHB7ZA== + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" + integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ== dependencies: has "^1.0.3" @@ -4215,9 +4160,9 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: is-extglob "^2.1.1" is-negative-zero@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461" - integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== is-number@^3.0.0: version "3.0.0" @@ -4493,9 +4438,9 @@ js-yaml@3.13.1: esprima "^4.0.0" js-yaml@^3.10.0, js-yaml@^3.12.0, js-yaml@^3.13.1: - version "3.14.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" - integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== dependencies: argparse "^1.0.7" esprima "^4.0.0" @@ -4618,9 +4563,9 @@ jszip@^3.1.3: set-immediate-shim "~1.0.1" just-extend@^4.0.2: - version "4.1.0" - resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.0.tgz#7278a4027d889601640ee0ce0e5a00b992467da4" - integrity sha512-ApcjaOdVTJ7y4r08xI5wIqpvwS48Q0PBG4DJROcEkH1f8MdAiNFyFxz3xoL0LWAVwjrwPYZdVHHxhRHcx/uGLA== + version "4.1.1" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.1.tgz#158f1fdb01f128c411dc8b286a7b4837b3545282" + integrity sha512-aWgeGFW67BP3e5181Ep1Fv2v8z//iBJfrvyTnq8wG86vEESwmonn1zPBJ0VfmT9CJq2FIT0VsETtrNFm2a+SHA== kareem@2.3.1: version "2.3.1" @@ -4833,6 +4778,13 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" @@ -5170,11 +5122,11 @@ mongodb@3.3.5: saslprep "^1.0.0" mongodb@^3.1.0: - version "3.6.1" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.1.tgz#2c5cc2a81456ba183e8c432d80e78732cc72dabd" - integrity sha512-uH76Zzr5wPptnjEKJRQnwTsomtFOU/kQEU8a9hKHr2M7y9qVk7Q4Pkv0EQVp88742z9+RwvsdTw6dRjDZCNu1g== + version "3.6.3" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.3.tgz#eddaed0cc3598474d7a15f0f2a5b04848489fd05" + integrity sha512-rOZuR0QkodZiM+UbQE5kDsJykBqWi0CL4Ec2i1nrGrUI3KO11r6Fbxskqmq3JK2NH7aW4dcccBuUujAP0ERl5w== dependencies: - bl "^2.2.0" + bl "^2.2.1" bson "^1.1.4" denque "^1.4.1" require_optional "^1.0.1" @@ -5242,11 +5194,16 @@ ms@2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@2.1.2, ms@^2.1.1: +ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + multimatch@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-4.0.0.tgz#8c3c0f6e3e8449ada0af3dd29efb491a375191b3" @@ -5269,9 +5226,9 @@ mute-stream@0.0.8, mute-stream@~0.0.4: integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== nan@^2.12.1: - version "2.14.1" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" - integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== + version "2.14.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" + integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== nanomatch@^1.2.9: version "1.2.13" @@ -5310,7 +5267,7 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== -neo-async@^2.5.0: +neo-async@^2.5.0, neo-async@^2.6.0: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== @@ -5399,6 +5356,11 @@ node-preload@^0.2.1: dependencies: process-on-spawn "^1.0.0" +nodemailer@6.4.17: + version "6.4.17" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.17.tgz#8de98618028953b80680775770f937243a7d7877" + integrity sha512-89ps+SBGpo0D4Bi5ZrxcrCiRFaMmkCt+gItMXQGzEtZVR3uAD3QAQIDoxTWnx3ky0Dwwy/dhFrQ+6NNGXpw/qQ== + normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" @@ -5508,9 +5470,9 @@ object-copy@^0.1.0: kind-of "^3.0.3" object-inspect@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" - integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== + version "1.9.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" + integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" @@ -5545,12 +5507,13 @@ object.assign@^4.1.0, object.assign@^4.1.1: object-keys "^1.1.1" object.getownpropertydescriptors@^2.0.3: - version "2.1.0" - resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" - integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg== + version "2.1.1" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.1.tgz#0dfda8d108074d9c563e80490c883b6661091544" + integrity sha512-6DtXgZ/lIZ9hqx4GtZETobXLR/ZLaa0aqV0kzbn80Rf8Z2e/XFnhA0I7p07N2wH8bBBltr2xQPi6sbKWAY2Eng== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" + es-abstract "^1.18.0-next.1" object.pick@^1.3.0: version "1.3.0" @@ -5627,9 +5590,9 @@ p-cancelable@^2.0.0: integrity sha512-wvPXDmbMmu2ksjkB4Z3nZWTSkJEb9lqVdMaCKpZUGJG9TMiNp9XcbG3fn9fPKjem04fJMJnXoyFPk2FmgiaiNg== p-each-series@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48" - integrity sha512-ZuRs1miPT4HrjFa+9fRfOFXxGJfORgelKV9f9nNOWw2gl6gVsRaVDOQP0+MI0G0wGKns1Yacsu0GjOFbTK0JFQ== + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a" + integrity sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA== p-finally@^1.0.0: version "1.0.0" @@ -5968,13 +5931,14 @@ postcss-modules-values@^2.0.0: postcss "^7.0.6" postcss-selector-parser@^6.0.0: - version "6.0.2" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" - integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg== + version "6.0.4" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz#56075a1380a04604c38b063ea7767a129af5c2b3" + integrity sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw== dependencies: cssesc "^3.0.0" indexes-of "^1.0.1" uniq "^1.0.1" + util-deprecate "^1.0.2" postcss-value-parser@^3.3.0, postcss-value-parser@^3.3.1: version "3.3.1" @@ -5982,9 +5946,9 @@ postcss-value-parser@^3.3.0, postcss-value-parser@^3.3.1: integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== postcss@^7.0.14, postcss@^7.0.5, postcss@^7.0.6: - version "7.0.32" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" - integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== + version "7.0.35" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" + integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== dependencies: chalk "^2.4.2" source-map "^0.6.1" @@ -6431,10 +6395,10 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" -readdirp@~3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" - integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== +readdirp@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" + integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== dependencies: picomatch "^2.2.1" @@ -6635,14 +6599,7 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.1.6, resolve@^1.10.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" - integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== - dependencies: - path-parse "^1.0.6" - -resolve@^1.3.2, resolve@^1.9.0: +resolve@^1.1.6, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.9.0: version "1.19.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== @@ -6837,9 +6794,11 @@ semver@^6.0.0, semver@^6.3.0: integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== semver@^7.1.3, semver@^7.2.1: - version "7.3.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" - integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== + version "7.3.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97" + integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw== + dependencies: + lru-cache "^6.0.0" send@0.16.2: version "0.16.2" @@ -6985,6 +6944,14 @@ shelljs@^0.8.3: interpret "^1.0.0" rechoir "^0.6.2" +short-uuid@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/short-uuid/-/short-uuid-4.1.0.tgz#c959f46101b4278e2589d5c85b4c35a385b51764" + integrity sha512-Zjerp00N5uUC7ET1mEjz77vY9h5zm6IQivtHxcbnoSIWyK6PD/dQnU5w916F8lzQIJjxBTEbCKsAikE64WxUxQ== + dependencies: + any-base "^1.1.0" + uuid "^8.3.0" + sift@7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/sift/-/sift-7.0.1.tgz#47d62c50b159d316f1372f8b53f9c10cd21a4b08" @@ -7093,11 +7060,11 @@ socket.io-client@2.2.0: to-array "0.1.4" socket.io-parser@~3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f" - integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng== + version "3.3.1" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.1.tgz#f07d9c8cb3fb92633aa93e76d98fd3a334623199" + integrity sha512-1QLvVAe8dTz+mKmZ07Swxt+LAo4Y1ff50rlyoEx00TQmDFVQYPfcqGvIDJLGaBdhdNCecXtyKpD+EgKGcmmbuQ== dependencies: - component-emitter "1.2.1" + component-emitter "~1.3.0" debug "~3.1.0" isarray "2.0.1" @@ -7113,6 +7080,11 @@ socket.io@2.2.0: socket.io-client "2.2.0" socket.io-parser "~3.3.0" +sortablejs@1.12.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.12.0.tgz#ee6d7ece3598c2af0feb1559d98595e5ea37cbd6" + integrity sha512-bPn57rCjBRlt2sC24RBsu40wZsmLkSo2XeqG8k6DC1zru5eObQUIPPZAQG7W2SJ8FZQYq+BEJmvuw1Zxb3chqg== + source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" @@ -7207,9 +7179,9 @@ spdx-expression-parse@^3.0.0: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.5" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654" - integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== + version "3.0.7" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" + integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" @@ -7346,20 +7318,20 @@ string-width@^4.1.0, string-width@^4.2.0: strip-ansi "^6.0.0" string.prototype.trimend@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.2.tgz#6ddd9a8796bc714b489a3ae22246a208f37bfa46" - integrity sha512-8oAG/hi14Z4nOVP0z6mdiVZ/wqjDtWSLygMigTzAb+7aPEDTleeFf+WrF+alzecxIRkckkJVn+dTlwzJXORATw== + version "1.0.3" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz#a22bd53cca5c7cf44d7c9d5c732118873d6cd18b" + integrity sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.18.0-next.1" string.prototype.trimstart@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.2.tgz#22d45da81015309cd0cdd79787e8919fc5c613e7" - integrity sha512-7F6CdBTl5zyu30BJFdzSTlSlLPwODC23Od+iLoVH8X6+3fvDPPuBVVj9iaB1GOsSTSIgVfsfm27R2FGrAPznWg== + version "1.0.3" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz#9b4cb590e123bb36564401d59824298de50fd5aa" + integrity sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.18.0-next.1" string_decoder@^1.0.0, string_decoder@^1.1.1: version "1.3.0" @@ -7628,9 +7600,9 @@ timed-out@4.0.1, timed-out@^4.0.0: integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8= timers-browserify@^2.0.4: - version "2.0.11" - resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.11.tgz#800b1f3eee272e5bc53ee465a04d0e804c31211f" - integrity sha512-60aV6sgJ5YEbzUdn9c8kYGIqOubPoUdqQCul3SBAsRCZ40s6Y5cMcrW4dt3/k/EsbLVJNl9n6Vz3fTc+k2GeKQ== + version "2.0.12" + resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.12.tgz#44a45c11fbf407f34f97bccd1577c652361b00ee" + integrity sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ== dependencies: setimmediate "^1.0.4" @@ -7714,9 +7686,9 @@ tough-cookie@~2.5.0: punycode "^2.1.1" tslib@^1.9.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" - integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== tslib@^2.0.1: version "2.0.3" @@ -7802,6 +7774,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +typescript@4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" + integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== + typical@^5.0.0, typical@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066" @@ -7817,6 +7794,11 @@ uglify-js@^2.6.1: optionalDependencies: uglify-to-browserify "~1.0.0" +uglify-js@^3.1.4: + version "3.12.5" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.12.5.tgz#83241496087c640efe9dfc934832e71725aba008" + integrity sha512-SgpgScL4T7Hj/w/GexjnBHi3Ien9WS1Rpfg5y91WXMj9SY997ZCQU76mH4TpLwwfmMvoOU8wiaRkIf6NaH3mtg== + uglify-to-browserify@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" @@ -7930,7 +7912,7 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -util-deprecate@^1.0.1, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= @@ -7964,6 +7946,11 @@ uuid@^3.3.2, uuid@^3.3.3: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^8.3.0: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz#9471efa3ef9128d2f7c6a7ca39c4dd6b5055b132" @@ -8024,23 +8011,23 @@ void-elements@^2.0.1: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= -watchpack-chokidar2@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0" - integrity sha512-9TyfOyN/zLUbA288wZ8IsMZ+6cbzvsNyEzSBp6e/zkifi6xxbl8SmQ/CxQq32k8NNqrdVEVUVSEf56L4rQ/ZxA== +watchpack-chokidar2@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz#38500072ee6ece66f3769936950ea1771be1c957" + integrity sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww== dependencies: chokidar "^2.1.8" watchpack@^1.5.0: - version "1.7.4" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.4.tgz#6e9da53b3c80bb2d6508188f5b200410866cd30b" - integrity sha512-aWAgTW4MoSJzZPAicljkO1hsi1oKj/RRq/OJQh2PKI2UKL04c2Bs+MBOB+BBABHTXJpf9mCwHN7ANCvYsvY2sg== + version "1.7.5" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.5.tgz#1267e6c55e0b9b5be44c2023aed5437a2c26c453" + integrity sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ== dependencies: graceful-fs "^4.1.2" neo-async "^2.5.0" optionalDependencies: chokidar "^3.4.1" - watchpack-chokidar2 "^2.0.0" + watchpack-chokidar2 "^2.0.1" webdriver-js-extender@2.1.0: version "2.1.0" @@ -8218,6 +8205,11 @@ wordwrap@0.0.2: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" integrity sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8= +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= + wordwrapjs@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.0.tgz#9aa9394155993476e831ba8e59fb5795ebde6800" @@ -8326,15 +8318,20 @@ y18n@^3.2.0: integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= y18n@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" - integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + version "4.0.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" + integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ== yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yaml-lint@1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/yaml-lint/-/yaml-lint-1.2.4.tgz#0dec2d1ef4e5ec999bba1e34d618fc60498d1bc5"