From 1a381232ec550c1d2173be386831ccef1b15345a Mon Sep 17 00:00:00 2001 From: webkom-R2D2 Date: Wed, 25 Nov 2020 09:58:43 +0100 Subject: [PATCH 01/98] Add initial implementation for the STV algorithm --- app/stv/stv.js | 278 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 app/stv/stv.js diff --git a/app/stv/stv.js b/app/stv/stv.js new file mode 100644 index 00000000..fcfcf602 --- /dev/null +++ b/app/stv/stv.js @@ -0,0 +1,278 @@ +/** Types used in this file + * type STV = { + * result: STVResult; + * log: STVEvent[]; + * thr: number; + * }; + * + * type Alternative = { + * _id: string; + * description: string; + * election: string; + * }; + * + * type Vote = { + * _id: string; + * priorities: Alternative[]; + * hash: string; + * weight: number; + * }; + * + * type STVEvent = + * | { + * action: 'ITERATION'; + * iteration: number; + * winners: Alternative[]; + * alternatives: Alternative[]; + * votes: Vote[]; + * counts: { [key: string]: number }; + * } + * | { + * action: 'WIN'; + * alternative: Alternative; + * voteCount: number; + * } + * | { + * action: 'ELIMINATE'; + * alternatives: Alternative[]; + * minScore: number; + * }; + * type STVResult = + * | { + * status: 'RESOLVED'; + * winners: Alternative[]; + * } + * | { + * status: 'UNRESOLVED'; + * winners: Alternative[]; + * }; + */ + +/** + * The Droop qouta https://en.wikipedia.org/wiki/Droop_quota + * @param { Vote[] } votes - All votes for the election + * @param { int } seats - The number of seats in this election + * + * @return { int } The amount votes needed to be elected + */ +const winningThreshold = (votes, seats) => { + 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 { Vote[] } votes - All votes for the election + * @param { Alternative[] } alternatives - All possible alternatives for the election + * @param { int } seats - The number of seats in this election + * + * @return { SVT } The full election, including result, log and threshold value + */ +exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { + // @let { SVTEvent[] } log - Will hold the log for the entire election + let log = []; + + // Stringify and clean the votes + votes = votes.map((vote) => ({ + _id: String(vote._id), + priorities: vote.priorities.map((alternative) => ({ + _id: String(alternative._id), + election: String(alternative.election), + description: alternative.description, + })), + hash: vote.hash, + weight: 1, + })); + + // Stingify and clean the alternatives + alternatives = alternatives.map((alternative) => ({ + _id: String(alternative._id), + description: alternative.description, + election: String(alternative.election), + })); + + // @const { int } thr - The threshold value needed to win + const thr = winningThreshold(votes, seats); + + // @let { Alternative[] } winners - Winners for the election + let winners = []; + + // @let { int } iteration - The election is a while loop, and with each iteration + // we count the number of first place votes each candidate has. + let iteration = 0; + while (votes.length > 0) { + iteration += 1; + + // Remove empty votes, this happens after the threshold is calculated + // in order to preserve "blank votes" + votes = votes.filter((vote) => vote.priorities.length > 0); + + // @let { [key: string]: float } counts - Dict with the counts for each candidate + let counts = {}; + + for (let i in votes) { + // @const { Vote } vote - The vote for this loop + const vote = votes[i]; + + // @const { Alternative } currentAlternative - We always count the first value (priorities[0]) + // because there is a mutation step that removed values that are "done". These are values + // connected to candidates that have either won or been eliminated from the election. + const currentAlternative = 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 + log.push({ + action: 'ITERATION', + iteration, + winners: winners.slice(), + // TODO find a better way to return this? + //alternatives: alternatives.slice(), + //votes: votes.slice(), + counts, + }); + + // @let { [key: string]: {} } roundWinner - Dict of winners + let roundWinners = {}; + // @let { [key: string]: float } excessFractions - Dict of excess fractions per key + let excessFractions = {}; + // @let { [key: string]: {} } doneVotes - Dict of done votes + let doneVotes = {}; + + // Loop over the different alternatives + for (let i in alternatives) { + // @const { Alternative } alternative - Get an alternative + const alternative = alternatives[i]; + // @const { float } voteCount - Find the number number of votes for this alternative + const voteCount = 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 + log.push({ + action: 'WIN', + alternative, + voteCount, + }); + + // Find the done Votes + for (let i in votes) { + // @const { Vote } vote - The vote for this loop + const vote = votes[i]; + + // Votes that have the winning alternative as their first pick + if (vote.priorities[0]._id === alternative._id) doneVotes[i] = {}; + } + } + } + + // @let { [key: string]: {} } doneAlternatives - Have won or been eliminated + let doneAlternatives = {}; + + // @let { Vote[] } nextRoundVotes - The votes that will go on to the next round + let nextRoundVotes = []; + + // 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: 'RESOLVED', winners }, + log, + thr, + }; + } + + // 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 (let i in doneVotes) { + // @const { Vote } vote - The vote for this loop + const vote = votes[i]; + + // @const { Alternative } alternative - Take the first choice of the done vote + const alternative = vote.priorities[0]; + + // @const { float } fraction - Find the excess fraction for this alternative + const fraction = 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 { + // If there are no new winners we must eliminate someone in order to progress the election + + // ================================================================================== + // TODO! Temp implementation to eliminate the users with the lowest score + const minScore = Math.min( + ...alternatives.map( + (alternative) => counts[alternative.description] || 0 + ) + ); + const minAlternatives = alternatives.filter( + (alternative) => + (counts[alternative.description] || 0) <= minScore + EPSILON + ); + + log.push({ + action: 'ELIMINATE', + alternatives: minAlternatives, + minScore, + }); + minAlternatives.forEach( + (alternatives) => (doneAlternatives[alternatives._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: 'UNRESOLVED', winners }, + log, + thr, + }; +}; + +// Round floats to fixed in output +// const handleFloatsInOutput = (obj) => { +// let newObj = {}; +// Object.entries(obj).forEach(([k, v]) => (newObj[k] = Number(v.toFixed(4)))); +// return newObj; +// }; From 0843db0ceb86d962e64be6582dbe1a852721196d Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Thu, 10 Dec 2020 17:03:16 +0100 Subject: [PATCH 02/98] Use Object.create() to avoid mutations --- app/stv/stv.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index fcfcf602..a776f8cd 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -114,12 +114,12 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { for (let i in votes) { // @const { Vote } vote - The vote for this loop - const vote = votes[i]; + const vote = Object.create(votes[i]); // @const { Alternative } currentAlternative - We always count the first value (priorities[0]) // because there is a mutation step that removed values that are "done". These are values // connected to candidates that have either won or been eliminated from the election. - const currentAlternative = vote.priorities[0]; + const currentAlternative = Object.create(vote.priorities[0]); // Use the alternatives description as key in the counts, and add one for each count counts[currentAlternative.description] = @@ -147,7 +147,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // Loop over the different alternatives for (let i in alternatives) { // @const { Alternative } alternative - Get an alternative - const alternative = alternatives[i]; + const alternative = Object.create(alternatives[i]); // @const { float } voteCount - Find the number number of votes for this alternative const voteCount = counts[alternative.description] || 0; @@ -173,7 +173,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // Find the done Votes for (let i in votes) { // @const { Vote } vote - The vote for this loop - const vote = votes[i]; + const vote = Object.create(votes[i]); // Votes that have the winning alternative as their first pick if (vote.priorities[0]._id === alternative._id) doneVotes[i] = {}; @@ -207,10 +207,10 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // Go through all done votes for (let i in doneVotes) { // @const { Vote } vote - The vote for this loop - const vote = votes[i]; + const vote = Object.create(votes[i]); // @const { Alternative } alternative - Take the first choice of the done vote - const alternative = vote.priorities[0]; + const alternative = Object.create(vote.priorities[0]); // @const { float } fraction - Find the excess fraction for this alternative const fraction = excessFractions[alternative._id] || 0; From 0b198ea28afad0233afe16b5701e5b4af73136b8 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Fri, 11 Dec 2020 17:03:11 +0100 Subject: [PATCH 03/98] Object.create() was stupid, use Stringify->Parse --- app/stv/stv.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index a776f8cd..5e3f9ffc 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -114,12 +114,12 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { for (let i in votes) { // @const { Vote } vote - The vote for this loop - const vote = Object.create(votes[i]); + const vote = JSON.parse(JSON.stringify(votes[i])); // @const { Alternative } currentAlternative - We always count the first value (priorities[0]) // because there is a mutation step that removed values that are "done". These are values // connected to candidates that have either won or been eliminated from the election. - const currentAlternative = Object.create(vote.priorities[0]); + const currentAlternative = JSON.parse(JSON.stringify(vote.priorities[0])); // Use the alternatives description as key in the counts, and add one for each count counts[currentAlternative.description] = @@ -147,7 +147,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // Loop over the different alternatives for (let i in alternatives) { // @const { Alternative } alternative - Get an alternative - const alternative = Object.create(alternatives[i]); + const alternative = JSON.parse(JSON.stringify(alternatives[i])); // @const { float } voteCount - Find the number number of votes for this alternative const voteCount = counts[alternative.description] || 0; @@ -173,7 +173,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // Find the done Votes for (let i in votes) { // @const { Vote } vote - The vote for this loop - const vote = Object.create(votes[i]); + const vote = JSON.parse(JSON.stringify(votes[i])); // Votes that have the winning alternative as their first pick if (vote.priorities[0]._id === alternative._id) doneVotes[i] = {}; @@ -207,10 +207,10 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // Go through all done votes for (let i in doneVotes) { // @const { Vote } vote - The vote for this loop - const vote = Object.create(votes[i]); + const vote = JSON.parse(JSON.stringify(votes[i])); // @const { Alternative } alternative - Take the first choice of the done vote - const alternative = Object.create(vote.priorities[0]); + const alternative = JSON.parse(JSON.stringify(vote.priorities[0])); // @const { float } fraction - Find the excess fraction for this alternative const fraction = excessFractions[alternative._id] || 0; From f149112842c9df15795ad0d053bd6b9f57be0c68 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Fri, 11 Dec 2020 17:28:36 +0100 Subject: [PATCH 04/98] Clean input and out better --- app/stv/stv.js | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index 5e3f9ffc..09d86c04 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -77,21 +77,13 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // Stringify and clean the votes votes = votes.map((vote) => ({ _id: String(vote._id), - priorities: vote.priorities.map((alternative) => ({ - _id: String(alternative._id), - election: String(alternative.election), - description: alternative.description, - })), + priorities: JSON.parse(JSON.stringify(vote.priorities)), hash: vote.hash, weight: 1, })); // Stingify and clean the alternatives - alternatives = alternatives.map((alternative) => ({ - _id: String(alternative._id), - description: alternative.description, - election: String(alternative.election), - })); + alternatives = JSON.parse(JSON.stringify(alternatives)); // @const { int } thr - The threshold value needed to win const thr = winningThreshold(votes, seats); @@ -131,10 +123,9 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { action: 'ITERATION', iteration, winners: winners.slice(), - // TODO find a better way to return this? - //alternatives: alternatives.slice(), - //votes: votes.slice(), - counts, + alternatives: alternatives.slice(), + votes: votes.slice(), + counts: handleFloatsInOutput(counts), }); // @let { [key: string]: {} } roundWinner - Dict of winners @@ -167,7 +158,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { log.push({ action: 'WIN', alternative, - voteCount, + voteCount: Number(voteCount.toFixed(4)), }); // Find the done Votes @@ -242,7 +233,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { log.push({ action: 'ELIMINATE', alternatives: minAlternatives, - minScore, + minScore: Number(minScore.toFixed(4)), }); minAlternatives.forEach( (alternatives) => (doneAlternatives[alternatives._id] = {}) @@ -271,8 +262,8 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { }; // Round floats to fixed in output -// const handleFloatsInOutput = (obj) => { -// let newObj = {}; -// Object.entries(obj).forEach(([k, v]) => (newObj[k] = Number(v.toFixed(4)))); -// return newObj; -// }; +const handleFloatsInOutput = (obj) => { + let newObj = {}; + Object.entries(obj).forEach(([k, v]) => (newObj[k] = Number(v.toFixed(4)))); + return newObj; +}; From 949f6d01d8382a885523fafbaad92465da8a107b Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Fri, 11 Dec 2020 21:59:55 +0100 Subject: [PATCH 05/98] Implement backward checking for eliminations ties --- app/stv/stv.js | 109 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 96 insertions(+), 13 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index 09d86c04..6a594b5b 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -34,8 +34,12 @@ * } * | { * action: 'ELIMINATE'; - * alternatives: Alternative[]; + * alternative: Alternative; * minScore: number; + * } + * | { + * action: 'TIE'; + * description: string; * }; * type STVResult = * | { @@ -216,28 +220,107 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { nextRoundVotes.push(vote); } } else { - // If there are no new winners we must eliminate someone in order to progress the election - - // ================================================================================== - // TODO! Temp implementation to eliminate the users with the lowest score + // Find the lowest score const minScore = Math.min( ...alternatives.map( (alternative) => counts[alternative.description] || 0 ) ); + + // Find the candidates with the lowest score const minAlternatives = alternatives.filter( (alternative) => (counts[alternative.description] || 0) <= minScore + EPSILON ); - log.push({ - action: 'ELIMINATE', - alternatives: minAlternatives, - minScore: Number(minScore.toFixed(4)), - }); - minAlternatives.forEach( - (alternatives) => (doneAlternatives[alternatives._id] = {}) - ); + // 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.push({ + action: 'TIE', + description: `There are ${ + minAlternatives.length + } candidates with a score of ${Number( + minScore.toFixed(4) + )} at iteration ${reverseIteration}`, + }); + + // if the minScore is 0 then we just dont bother, and just expell all the candidates with 0 + if (minScore === 0) { + minAlternatives.forEach((alternative) => { + log.push({ + action: 'ELIMINATE', + alternative: alternative, + minScore: Number(minScore.toFixed(4)), + }); + doneAlternatives[alternative._id] = {}; + }); + } else { + while (reverseIteration > 0) { + // If we are at iteartion one with a tie + if (reverseIteration === 1) { + // There is nothing we can do + log.push({ + action: 'TIE', + description: + 'The backward checking went to iteration 1 without breaking the tie', + }); + return { + result: { status: 'UNRESOLVED', winners }, + log, + thr, + }; + } + // If we are not at iteration one we can enumerate backwards to see if we can find a diff + else { + reverseIteration--; + // Find the log object for the last iteration + const logObject = log.find( + (entry) => 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]) + ); + + // Find the candidates (in regard to the actual iteration) that has the lowest score + const iterationMinAlternatives = alternatives.filter( + (alternative) => + (logObject.counts[alternative.description] || 0) <= + iterationMinScore + EPSILON + ); + + // 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) { + log.push({ + action: 'ELIMINATE', + alternative: minAlternative, + minScore: Number(iterationMinScore.toFixed(4)), + }); + doneAlternatives[minAlternative._id] = {}; + } + break; + } + } + } + } else { + // There is only one candidate with the lowest score + const minAlternative = minAlternatives[0]; + if (minAlternative) { + log.push({ + action: 'ELIMINATE', + alternative: minAlternative, + minScore: Number(minScore.toFixed(4)), + }); + doneAlternatives[minAlternative._id] = {}; + } + } + // ================================================================================== nextRoundVotes = votes; } From a2fdb97b6923342dd9dbb1bb1fc81ebbb4419b9e Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 12 Dec 2020 14:18:22 +0100 Subject: [PATCH 06/98] Eliminate a random candidate if they have 0 or 1 in score --- app/stv/stv.js | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index 6a594b5b..376d471b 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -38,6 +38,11 @@ * minScore: number; * } * | { + * action: 'RANDOMELIMINATE'; + * alternative: Alternative; + * minScore: number; + * } + * | { * action: 'TIE'; * description: string; * }; @@ -127,8 +132,9 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { action: 'ITERATION', iteration, winners: winners.slice(), - alternatives: alternatives.slice(), - votes: votes.slice(), + // TODO Find a better way to this, the test assertions are slow AF with this + //alternatives: alternatives.slice(), + //votes: votes.slice(), counts: handleFloatsInOutput(counts), }); @@ -245,16 +251,18 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { )} at iteration ${reverseIteration}`, }); - // if the minScore is 0 then we just dont bother, and just expell all the candidates with 0 - if (minScore === 0) { - minAlternatives.forEach((alternative) => { - log.push({ - action: 'ELIMINATE', - alternative: alternative, - minScore: Number(minScore.toFixed(4)), - }); - doneAlternatives[alternative._id] = {}; + // If the minScore is 0 or 1 we eliminate a random candidate as Scottish STV specifies + // This is only done in the low cases of 0 or 1, and we would rather return an unresolved + // election if the TIE cannot be solved by backtracking. + if (minScore === 0 || minScore === 1) { + const randomAlternative = + minAlternatives[Math.floor(Math.random() * minAlternatives.length)]; + log.push({ + action: 'RANDOMELIMINATE', + alternative: randomAlternative, + minScore: Number(minScore.toFixed(4)), }); + doneAlternatives[randomAlternative._id] = {}; } else { while (reverseIteration > 0) { // If we are at iteartion one with a tie @@ -285,7 +293,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { ); // Find the candidates (in regard to the actual iteration) that has the lowest score - const iterationMinAlternatives = alternatives.filter( + const iterationMinAlternatives = minAlternatives.filter( (alternative) => (logObject.counts[alternative.description] || 0) <= iterationMinScore + EPSILON From 6f47eb74c3bd26f848342bd2beb7061fc99b838a Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 12 Dec 2020 14:26:08 +0100 Subject: [PATCH 07/98] minScore of 1 was not the best idea, as backtracking might break the tie here --- app/stv/stv.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index 376d471b..1a95ce84 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -251,10 +251,10 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { )} at iteration ${reverseIteration}`, }); - // If the minScore is 0 or 1 we eliminate a random candidate as Scottish STV specifies - // This is only done in the low cases of 0 or 1, and we would rather return an unresolved + // If the minScore is 0 we eliminate a random candidate as Scottish STV specifies + // This is only done in the low case of 0, and we would rather return an unresolved // election if the TIE cannot be solved by backtracking. - if (minScore === 0 || minScore === 1) { + if (minScore === 0) { const randomAlternative = minAlternatives[Math.floor(Math.random() * minAlternatives.length)]; log.push({ From af7a6fe98f5a0961e2e94858a039a8eef6d2930a Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 12 Dec 2020 16:37:32 +0100 Subject: [PATCH 08/98] Remove minScore logic, and always eliminate ties --- app/stv/stv.js | 125 +++++++++++++++++++++++-------------------------- 1 file changed, 59 insertions(+), 66 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index 1a95ce84..7e4502b5 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -38,7 +38,7 @@ * minScore: number; * } * | { - * action: 'RANDOMELIMINATE'; + * action: 'TIEELIMINATE'; * alternative: Alternative; * minScore: number; * } @@ -77,10 +77,10 @@ const EPSILON = 0.000001; * @param { Alternative[] } alternatives - All possible alternatives for the election * @param { int } seats - The number of seats in this election * - * @return { SVT } The full election, including result, log and threshold value + * @return { STV } The full election, including result, log and threshold value */ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { - // @let { SVTEvent[] } log - Will hold the log for the entire election + // @let { STVEvent[] } log - Will hold the log for the entire election let log = []; // Stringify and clean the votes @@ -91,7 +91,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { weight: 1, })); - // Stingify and clean the alternatives + // Stringify and clean the alternatives alternatives = JSON.parse(JSON.stringify(alternatives)); // @const { int } thr - The threshold value needed to win @@ -103,7 +103,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // @let { int } iteration - The election is a while loop, and with each iteration // we count the number of first place votes each candidate has. let iteration = 0; - while (votes.length > 0) { + while (votes.length > 0 && iteration < 100) { iteration += 1; // Remove empty votes, this happens after the threshold is calculated @@ -242,6 +242,8 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // 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 log.push({ action: 'TIE', description: `There are ${ @@ -251,70 +253,61 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { )} at iteration ${reverseIteration}`, }); - // If the minScore is 0 we eliminate a random candidate as Scottish STV specifies - // This is only done in the low case of 0, and we would rather return an unresolved - // election if the TIE cannot be solved by backtracking. - if (minScore === 0) { - const randomAlternative = - minAlternatives[Math.floor(Math.random() * minAlternatives.length)]; - log.push({ - action: 'RANDOMELIMINATE', - alternative: randomAlternative, - minScore: Number(minScore.toFixed(4)), - }); - doneAlternatives[randomAlternative._id] = {}; - } else { - while (reverseIteration > 0) { - // If we are at iteartion one with a tie - if (reverseIteration === 1) { - // There is nothing we can do + // As long as the reveseindex is still larger then 1 we can look further back + while (reverseIteration >= 1) { + reverseIteration--; + + // Find the log object for the last iteration + const logObject = log.find( + (entry) => 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) => + (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) { + log.push({ + action: 'TIE', + description: + 'The backward checking went to iteration 1 without breaking the tie', + }); + // Eliminate all candidates that are in the last iterationMinAlternatives + iterationMinAlternatives.forEach((alternative) => { + // Log the tie elimination log.push({ - action: 'TIE', - description: - 'The backward checking went to iteration 1 without breaking the tie', + action: 'TIEELIMINATE', + alternative: alternative, + minScore: Number(minScore.toFixed(4)), }); - return { - result: { status: 'UNRESOLVED', winners }, - log, - thr, - }; - } - // If we are not at iteration one we can enumerate backwards to see if we can find a diff - else { - reverseIteration--; - // Find the log object for the last iteration - const logObject = log.find( - (entry) => 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]) - ); - - // Find the candidates (in regard to the actual iteration) that has the lowest score - const iterationMinAlternatives = minAlternatives.filter( - (alternative) => - (logObject.counts[alternative.description] || 0) <= - iterationMinScore + EPSILON - ); - - // 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) { - log.push({ - action: 'ELIMINATE', - alternative: minAlternative, - minScore: Number(iterationMinScore.toFixed(4)), - }); - doneAlternatives[minAlternative._id] = {}; - } - break; - } + doneAlternatives[alternative._id] = {}; + }); + break; + } + + // 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) { + log.push({ + action: 'ELIMINATE', + alternative: minAlternative, + minScore: Number(iterationMinScore.toFixed(4)), + }); + doneAlternatives[minAlternative._id] = {}; } + break; } } else { // There is only one candidate with the lowest score From cdcbbf35853ded48ecb714cbcbae85a04ab0b7aa Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 12 Dec 2020 19:23:04 +0100 Subject: [PATCH 09/98] Don't decrement iterations before current is checked --- app/stv/stv.js | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index 7e4502b5..b79d0b1e 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -38,8 +38,8 @@ * minScore: number; * } * | { - * action: 'TIEELIMINATE'; - * alternative: Alternative; + * action: 'MULTI_TIE_ELIMINATIONS'; + * alternatives: Alternative[]; * minScore: number; * } * | { @@ -255,8 +255,6 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // As long as the reveseindex is still larger then 1 we can look further back while (reverseIteration >= 1) { - reverseIteration--; - // Find the log object for the last iteration const logObject = log.find( (entry) => entry.iteration === reverseIteration @@ -282,18 +280,18 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { 'The backward checking went to iteration 1 without breaking the tie', }); // Eliminate all candidates that are in the last iterationMinAlternatives - iterationMinAlternatives.forEach((alternative) => { - // Log the tie elimination - log.push({ - action: 'TIEELIMINATE', - alternative: alternative, - minScore: Number(minScore.toFixed(4)), - }); - doneAlternatives[alternative._id] = {}; + log.push({ + action: 'MULTI_TIE_ELIMINATIONS', + alternatives: alternatives, + minScore: Number(minScore.toFixed(4)), }); + 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; @@ -321,8 +319,6 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { doneAlternatives[minAlternative._id] = {}; } } - - // ================================================================================== nextRoundVotes = votes; } From fd5d3fdd381df2978198d20a668ed97266054c7b Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 14 Dec 2020 12:38:48 +0100 Subject: [PATCH 10/98] Add sests and voteCount to output. Use iterationMin in log --- app/stv/stv.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index b79d0b1e..4895929d 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -3,6 +3,8 @@ * result: STVResult; * log: STVEvent[]; * thr: number; + * seats: number; + * voteCount: number; * }; * * type Alternative = { @@ -196,6 +198,8 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { result: { status: 'RESOLVED', winners }, log, thr, + seats, + voteCount: votes.length, }; } @@ -282,7 +286,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { // Eliminate all candidates that are in the last iterationMinAlternatives log.push({ action: 'MULTI_TIE_ELIMINATIONS', - alternatives: alternatives, + alternatives: iterationMinAlternatives, minScore: Number(minScore.toFixed(4)), }); iterationMinAlternatives.forEach( @@ -338,6 +342,8 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { result: { status: 'UNRESOLVED', winners }, log, thr, + seats, + voteCount: votes.length, }; }; From 5660ed37ad986dbe61c8404ea54ae7342d51e515 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 14 Dec 2020 18:00:08 +0100 Subject: [PATCH 11/98] Use inputVote length when returning result --- app/stv/stv.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index 4895929d..b85ce6a6 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -81,12 +81,12 @@ const EPSILON = 0.000001; * * @return { STV } The full election, including result, log and threshold value */ -exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { +exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats) => { // @let { STVEvent[] } log - Will hold the log for the entire election let log = []; // Stringify and clean the votes - votes = votes.map((vote) => ({ + let votes = inputVotes.map((vote) => ({ _id: String(vote._id), priorities: JSON.parse(JSON.stringify(vote.priorities)), hash: vote.hash, @@ -94,7 +94,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { })); // Stringify and clean the alternatives - alternatives = JSON.parse(JSON.stringify(alternatives)); + let alternatives = JSON.parse(JSON.stringify(inputAlternatives)); // @const { int } thr - The threshold value needed to win const thr = winningThreshold(votes, seats); @@ -134,9 +134,6 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { action: 'ITERATION', iteration, winners: winners.slice(), - // TODO Find a better way to this, the test assertions are slow AF with this - //alternatives: alternatives.slice(), - //votes: votes.slice(), counts: handleFloatsInOutput(counts), }); @@ -199,7 +196,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { log, thr, seats, - voteCount: votes.length, + voteCount: inputVotes.length, }; } @@ -343,7 +340,7 @@ exports.calculateWinnerUsingSTV = (votes, alternatives, seats) => { log, thr, seats, - voteCount: votes.length, + voteCount: inputVotes.length, }; }; From 0b7df38824decbde8e7fd49eb9e8d8c70d3b4698 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 15 Dec 2020 17:39:54 +0100 Subject: [PATCH 12/98] Implement useStrict for STV elections, forces 67% --- app/errors/index.js | 11 +++++++++++ app/stv/stv.js | 26 ++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/app/errors/index.js b/app/errors/index.js index 6749f9e5..20214fe4 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -170,6 +170,17 @@ class DuplicateUsernameError extends Error { exports.DuplicateUsernameError = DuplicateUsernameError; +class StrictWithoutOneSeatError extends Error { + constructor() { + super(); + this.name = 'StrictWithoutOneSeatError'; + this.message = 'Cannot have a strict election with more then one seat.'; + this.status = 400; + } +} + +exports.StrictWithoutOneSeatError = StrictWithoutOneSeatError; + exports.handleError = (res, err, status) => { const statusCode = status || err.status || 500; return res.status(statusCode).json( diff --git a/app/stv/stv.js b/app/stv/stv.js index b85ce6a6..07d0a188 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -1,3 +1,5 @@ +const errors = require('../errors'); + /** Types used in this file * type STV = { * result: STVResult; @@ -5,6 +7,7 @@ * thr: number; * seats: number; * voteCount: number; + * useStrict: boolean; * }; * * type Alternative = { @@ -63,10 +66,14 @@ * The Droop qouta https://en.wikipedia.org/wiki/Droop_quota * @param { Vote[] } votes - All votes for the election * @param { int } seats - The number of seats in this election + * @param { boolean } useStrict - Sets the threshold to 67% no matter what * * @return { int } The amount votes needed to be elected */ -const winningThreshold = (votes, seats) => { +const winningThreshold = (votes, seats, useStrict) => { + if (useStrict) { + return Math.floor((2 * votes.length) / 3) + 1; + } return Math.floor(votes.length / (seats + 1) + 1); }; @@ -77,11 +84,20 @@ const EPSILON = 0.000001; * Will calculate the election result using Single Transferable Vote * @param { Vote[] } votes - All votes for the election * @param { Alternative[] } alternatives - All possible alternatives for the election - * @param { int } seats - The number of seats in this election + * @param { int } seats - The number of seats in this election. Default 1 + * @param { boolean } useStrict - This election will require a qualified majority. Default false * * @return { STV } The full election, including result, log and threshold value */ -exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats) => { +exports.calculateWinnerUsingSTV = ( + inputVotes, + inputAlternatives, + seats = 1, + useStrict = false +) => { + // Check that this election does not violate the strict constraint + if (useStrict && seats !== 1) throw new errors.StrictWithoutOneSeatError(); + // @let { STVEvent[] } log - Will hold the log for the entire election let log = []; @@ -97,7 +113,7 @@ exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats) => { let alternatives = JSON.parse(JSON.stringify(inputAlternatives)); // @const { int } thr - The threshold value needed to win - const thr = winningThreshold(votes, seats); + const thr = winningThreshold(votes, seats, useStrict); // @let { Alternative[] } winners - Winners for the election let winners = []; @@ -197,6 +213,7 @@ exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats) => { thr, seats, voteCount: inputVotes.length, + useStrict, }; } @@ -341,6 +358,7 @@ exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats) => { thr, seats, voteCount: inputVotes.length, + useStrict, }; }; From de67f45f9781ccc262c695c67034365e80b5fd88 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Thu, 17 Dec 2020 18:01:15 +0100 Subject: [PATCH 13/98] Apply suggestions from code review Co-authored-by: Ludvig --- app/stv/stv.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index 07d0a188..dd1f61a3 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -136,7 +136,7 @@ exports.calculateWinnerUsingSTV = ( const vote = JSON.parse(JSON.stringify(votes[i])); // @const { Alternative } currentAlternative - We always count the first value (priorities[0]) - // because there is a mutation step that removed values that are "done". These are values + // 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 = JSON.parse(JSON.stringify(vote.priorities[0])); @@ -164,7 +164,7 @@ exports.calculateWinnerUsingSTV = ( for (let i in alternatives) { // @const { Alternative } alternative - Get an alternative const alternative = JSON.parse(JSON.stringify(alternatives[i])); - // @const { float } voteCount - Find the number number of votes for this alternative + // @const { float } voteCount - Find the number of votes for this alternative const voteCount = counts[alternative.description] || 0; // If an alternative has enough votes, add them as round winner @@ -271,7 +271,7 @@ exports.calculateWinnerUsingSTV = ( )} at iteration ${reverseIteration}`, }); - // As long as the reveseindex is still larger then 1 we can look further back + // 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 = log.find( From a372697e3b3d11a796baf0018eb8d28521d3d2fa Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 12 Jan 2021 19:04:06 +0100 Subject: [PATCH 14/98] Add original .ts file for stv --- .prettierignore | 6 + app/errors/index.js | 11 - app/stv/stv.js | 537 +++++++++++++++----------------------------- app/stv/stv.ts | 415 ++++++++++++++++++++++++++++++++++ package.json | 3 +- tsconfig.json | 15 ++ yarn.lock | 5 + 7 files changed, 621 insertions(+), 371 deletions(-) create mode 100644 .prettierignore create mode 100644 app/stv/stv.ts create mode 100644 tsconfig.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/app/errors/index.js b/app/errors/index.js index 20214fe4..6749f9e5 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -170,17 +170,6 @@ class DuplicateUsernameError extends Error { exports.DuplicateUsernameError = DuplicateUsernameError; -class StrictWithoutOneSeatError extends Error { - constructor() { - super(); - this.name = 'StrictWithoutOneSeatError'; - this.message = 'Cannot have a strict election with more then one seat.'; - this.status = 400; - } -} - -exports.StrictWithoutOneSeatError = StrictWithoutOneSeatError; - exports.handleError = (res, err, status) => { const statusCode = status || err.status || 500; return res.status(statusCode).json( diff --git a/app/stv/stv.js b/app/stv/stv.js index dd1f61a3..dbf110da 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -1,370 +1,189 @@ -const errors = require('../errors'); - -/** Types used in this file - * type STV = { - * result: STVResult; - * log: STVEvent[]; - * thr: number; - * seats: number; - * voteCount: number; - * useStrict: boolean; - * }; - * - * type Alternative = { - * _id: string; - * description: string; - * election: string; - * }; - * - * type Vote = { - * _id: string; - * priorities: Alternative[]; - * hash: string; - * weight: number; - * }; - * - * type STVEvent = - * | { - * action: 'ITERATION'; - * iteration: number; - * winners: Alternative[]; - * alternatives: Alternative[]; - * votes: Vote[]; - * counts: { [key: string]: number }; - * } - * | { - * action: 'WIN'; - * alternative: Alternative; - * voteCount: number; - * } - * | { - * action: 'ELIMINATE'; - * alternative: Alternative; - * minScore: number; - * } - * | { - * action: 'MULTI_TIE_ELIMINATIONS'; - * alternatives: Alternative[]; - * minScore: number; - * } - * | { - * action: 'TIE'; - * description: string; - * }; - * type STVResult = - * | { - * status: 'RESOLVED'; - * winners: Alternative[]; - * } - * | { - * status: 'UNRESOLVED'; - * winners: Alternative[]; - * }; - */ - -/** - * The Droop qouta https://en.wikipedia.org/wiki/Droop_quota - * @param { Vote[] } votes - All votes for the election - * @param { int } seats - The number of seats in this election - * @param { boolean } useStrict - Sets the threshold to 67% no matter what - * - * @return { int } The amount votes needed to be elected - */ +"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); + 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 { Vote[] } votes - All votes for the election - * @param { Alternative[] } alternatives - All possible alternatives for the election - * @param { int } seats - The number of seats in this election. Default 1 - * @param { boolean } useStrict - This election will require a qualified majority. Default false - * - * @return { STV } The full election, including result, log and threshold value - */ -exports.calculateWinnerUsingSTV = ( - inputVotes, - inputAlternatives, - seats = 1, - useStrict = false -) => { - // Check that this election does not violate the strict constraint - if (useStrict && seats !== 1) throw new errors.StrictWithoutOneSeatError(); - - // @let { STVEvent[] } log - Will hold the log for the entire election - let log = []; - - // Stringify and clean the votes - let votes = inputVotes.map((vote) => ({ - _id: String(vote._id), - priorities: JSON.parse(JSON.stringify(vote.priorities)), - hash: vote.hash, - weight: 1, - })); - - // Stringify and clean the alternatives - let alternatives = JSON.parse(JSON.stringify(inputAlternatives)); - - // @const { int } thr - The threshold value needed to win - const thr = winningThreshold(votes, seats, useStrict); - - // @let { Alternative[] } winners - Winners for the election - let winners = []; - - // @let { int } iteration - The election is a while loop, and 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, this happens after the threshold is calculated - // in order to preserve "blank votes" - votes = votes.filter((vote) => vote.priorities.length > 0); - - // @let { [key: string]: float } counts - Dict with the counts for each candidate - let counts = {}; - - for (let i in votes) { - // @const { Vote } vote - The vote for this loop - const vote = JSON.parse(JSON.stringify(votes[i])); - - // @const { Alternative } currentAlternative - 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 = JSON.parse(JSON.stringify(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 - log.push({ - action: 'ITERATION', - iteration, - winners: winners.slice(), - counts: handleFloatsInOutput(counts), - }); - - // @let { [key: string]: {} } roundWinner - Dict of winners - let roundWinners = {}; - // @let { [key: string]: float } excessFractions - Dict of excess fractions per key - let excessFractions = {}; - // @let { [key: string]: {} } doneVotes - Dict of done votes - let doneVotes = {}; - - // Loop over the different alternatives - for (let i in alternatives) { - // @const { Alternative } alternative - Get an alternative - const alternative = JSON.parse(JSON.stringify(alternatives[i])); - // @const { float } voteCount - Find the number of votes for this alternative - const voteCount = 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 - log.push({ - action: 'WIN', - alternative, - voteCount: Number(voteCount.toFixed(4)), - }); - - // Find the done Votes - for (let i in votes) { - // @const { Vote } vote - The vote for this loop - const vote = JSON.parse(JSON.stringify(votes[i])); - - // Votes that have the winning alternative as their first pick - if (vote.priorities[0]._id === alternative._id) doneVotes[i] = {}; +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 winners = []; + let iteration = 0; + while (votes.length > 0 && iteration < 100) { + iteration += 1; + votes = votes.filter((vote) => vote.priorities.length > 0); + const counts = {}; + 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); } - } - } - - // @let { [key: string]: {} } doneAlternatives - Have won or been eliminated - let doneAlternatives = {}; - - // @let { Vote[] } nextRoundVotes - The votes that will go on to the next round - let nextRoundVotes = []; - - // 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: 'RESOLVED', winners }, - log, - thr, - seats, - voteCount: inputVotes.length, - useStrict, + const iterationLog = { + action: Action.iteration, + iteration, + winners: winners.slice(), + counts: handleFloatsInOutput(counts), }; - } - - // 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 (let i in doneVotes) { - // @const { Vote } vote - The vote for this loop - const vote = JSON.parse(JSON.stringify(votes[i])); - - // @const { Alternative } alternative - Take the first choice of the done vote - const alternative = JSON.parse(JSON.stringify(vote.priorities[0])); - - // @const { float } fraction - Find the excess fraction for this alternative - const fraction = 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 = Math.min( - ...alternatives.map( - (alternative) => counts[alternative.description] || 0 - ) - ); - - // Find the candidates with the lowest score - const minAlternatives = 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 - log.push({ - action: 'TIE', - description: `There are ${ - minAlternatives.length - } candidates with a score of ${Number( - minScore.toFixed(4) - )} at iteration ${reverseIteration}`, - }); - - // 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 = log.find( - (entry) => 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) => - (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) { - log.push({ - action: 'TIE', - description: - 'The backward checking went to iteration 1 without breaking the tie', - }); - // Eliminate all candidates that are in the last iterationMinAlternatives - log.push({ - action: 'MULTI_TIE_ELIMINATIONS', - alternatives: iterationMinAlternatives, - minScore: Number(minScore.toFixed(4)), - }); - 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) { - log.push({ - action: 'ELIMINATE', - alternative: minAlternative, - minScore: Number(iterationMinScore.toFixed(4)), - }); - doneAlternatives[minAlternative._id] = {}; - } - break; + 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, + 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 { - // There is only one candidate with the lowest score - const minAlternative = minAlternatives[0]; - if (minAlternative) { - log.push({ - action: 'ELIMINATE', - alternative: minAlternative, - minScore: Number(minScore.toFixed(4)), - }); - doneAlternatives[minAlternative._id] = {}; + 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; } - } - nextRoundVotes = votes; + votes = nextRoundVotes.map((vote) => { + vote['priorities'] = vote.priorities.filter((alternative) => !doneAlternatives[alternative._id]); + return vote; + }); + alternatives = alternatives.filter((alternative) => !doneAlternatives[alternative._id]); } - - // 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: 'UNRESOLVED', winners }, - log, - thr, - seats, - voteCount: inputVotes.length, - useStrict, - }; + return { + result: { status: Status.unresolved, winners }, + log, + thr, + seats, + voteCount: inputVotes.length, + useStrict, + }; }; - -// Round floats to fixed in output const handleFloatsInOutput = (obj) => { - let newObj = {}; - Object.entries(obj).forEach(([k, v]) => (newObj[k] = Number(v.toFixed(4)))); - return newObj; + 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..8a548660 --- /dev/null +++ b/app/stv/stv.ts @@ -0,0 +1,415 @@ +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; + useStrict: boolean; +}; + +type Alternative = { + _id: string; + description: string; + election: string; +}; + +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); + + // 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: { [key: string]: number } = {}; + + 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, + 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, + 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/package.json b/package.json index a0336667..3c8fa3ff 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "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", @@ -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", 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..61fa3d7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -508,6 +508,11 @@ 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" From 8491ff88e72175bffe55c9b9446120c40de2fca9 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 28 Nov 2020 14:07:54 +0100 Subject: [PATCH 15/98] Update model schemas, and move addVote method to election --- app/models/alternative.js | 57 -------------------- app/models/election.js | 108 ++++++++++++++++++++++++++++++++------ app/models/vote.js | 10 +++- 3 files changed, 101 insertions(+), 74 deletions(-) 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..a33b8cbf 100644 --- a/app/models/election.js +++ b/app/models/election.js @@ -1,9 +1,19 @@ +const _ = require('lodash'); const Bluebird = require('bluebird'); const errors = require('../errors'); const mongoose = require('mongoose'); const Vote = require('./vote'); const Schema = mongoose.Schema; +const stv = require('../stv/stv.js'); + +const env = require('../../env'); +const redisClient = require('redis').createClient(6379, env.REDIS_URL); +const Redlock = require('redlock'); +const redlock = new Redlock([redisClient], {}); + +const crypto = require('crypto'); + const hasVotedSchema = new Schema({ user: { type: Schema.Types.ObjectId, @@ -20,43 +30,71 @@ const electionSchema = new Schema({ description: { type: String, }, + active: { + type: Boolean, + default: false, + }, + hasVotedUsers: [hasVotedSchema], alternatives: [ { type: Schema.Types.ObjectId, ref: 'Alternative', }, ], - active: { - type: Boolean, - default: false, + seats: { + type: Number, + default: 1, }, - hasVotedUsers: [hasVotedSchema], + votes: [ + { + type: Schema.Types.ObjectId, + ref: 'Vote', + }, + ], }); +// TODO 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 ); }; @@ -68,4 +106,44 @@ 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(); + + const lock = await redlock.lock('vote:' + user.username, 2000); + if (!this.active) { + await lock.unlock(); + throw new errors.InactiveElectionError(); + } + const votedUsers = this.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, + election: this.id, + priorities: priorities, + }); + + this.hasVotedUsers.push({ user: user._id }); + await this.save(); + + const savedVote = await vote.save(); + this.votes.push(savedVote._id); + + await this.save(); + + await lock.unlock(); + + return savedVote; +}; + module.exports = mongoose.model('Election', electionSchema); 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); From 3c2785fcab8983724abb934d37e0f60c2a65bc30 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 28 Nov 2020 14:14:09 +0100 Subject: [PATCH 16/98] Rename sumVotes to elect for election api --- app/controllers/election.js | 4 ++-- app/routes/api/election.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/election.js b/app/controllers/election.js index 2c4a68f4..6d1141f8 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -81,8 +81,8 @@ exports.deactivate = (req, res) => 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/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; From f3754608d38d3c5cc78137202126e2d0a8558a73 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 28 Nov 2020 14:15:19 +0100 Subject: [PATCH 17/98] Rewrite create and retrive methods for votes --- app/controllers/vote.js | 54 +++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/app/controllers/vote.js b/app/controllers/vote.js index cec83ca5..f382c286 100644 --- a/app/controllers/vote.js +++ b/app/controllers/vote.js @@ -1,26 +1,39 @@ -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'); +exports.create = async (req, res) => { + const { election, priorities } = req.body; + + 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); + return Election.findById(req.body.election._id) + .then((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); + } + + return election.addVote(req.user, priorities); }) - .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.json(vote)); }; exports.retrieve = async (req, res) => { @@ -30,10 +43,9 @@ 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'); if (!vote) throw new errors.NotFoundError('vote'); res.json(vote); From ec536d090d2c12110d84e18c7425cd1cb7542300 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 28 Nov 2020 14:18:50 +0100 Subject: [PATCH 18/98] New error types --- app/errors/index.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/errors/index.js b/app/errors/index.js index 6749f9e5..805fd3bb 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -64,6 +64,28 @@ class InvalidPayloadError extends Error { exports.InvalidPayloadError = InvalidPayloadError; +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(); From d8cb492ea9d26d908d7695eb2eee24026652c03f Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 28 Nov 2020 14:23:20 +0100 Subject: [PATCH 19/98] Emit websocket on election deactivation --- app/controllers/election.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/controllers/election.js b/app/controllers/election.js index 6d1141f8..439cae1b 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -77,9 +77,11 @@ exports.activate = (req, res) => }); 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.elect = (req, res) => req.election.elect().then((result) => res.json(result)); From b973f45b658d2c4f9bf376a94af57ce0032c589b Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 28 Nov 2020 14:31:34 +0100 Subject: [PATCH 20/98] Add and rewrite election and vote tests --- test/api/election.test.js | 11 +- test/api/vote.test.js | 219 ++++++++++++++++++++++++++++++-------- 2 files changed, 181 insertions(+), 49 deletions(-) diff --git a/test/api/election.test.js b/test/api/election.test.js index 62d59e0f..8dd0fc2c 100644 --- a/test/api/election.test.js +++ b/test/api/election.test.js @@ -264,7 +264,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 +299,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 +317,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); @@ -384,7 +389,7 @@ describe('Election API', () => { 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/vote.test.js b/test/api/vote.test.js index 69c8a7b9..bb211554 100644 --- a/test/api/vote.test.js +++ b/test/api/vote.test.js @@ -8,8 +8,10 @@ const Election = require('../../app/models/election'); const Vote = require('../../app/models/vote'); const { test404, testAdminResource } = require('./helpers'); const { createUsers } = require('../helpers'); +const chaiSubset = require('chai-subset'); const should = chai.should(); +chai.use(chaiSubset); describe('Vote API', () => { const activeElectionData = { @@ -35,11 +37,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 +59,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 +80,178 @@ 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 a nonexistent alternativeId', async () => { + 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(new ObjectId())) + .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 on a nonexistent election', async () => { + const { body: error } = await request(app) + .post('/api/vote') + .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 without an alternativeId in the payload', async () => { + 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 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('Missing property alternativeId from payload.'); + 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( + '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('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('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('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 +264,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 +272,7 @@ describe('Vote API', () => { passportStub.logout(); const { body: error } = await request(app) .post('/api/vote') - .send(votePayload(this.activeAlternative.id)) + .send(votePayload({}, [])) .expect(401) .expect('Content-Type', /json/); error.status.should.equal(401); @@ -175,7 +286,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 +301,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 +312,27 @@ 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, + ]); + 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)); }); it('should return 400 when retrieving votes without header', async () => { @@ -230,14 +348,23 @@ 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.should.containSubset({ + thr: 1, + result: { + status: 'RESOLVED', + }, + }); }); it('should not be possible to get votes on an active election', async function () { @@ -281,7 +408,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 +421,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/); From f2f035573f5963fa43febe88eae18db0b37aa360 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Thu, 10 Dec 2020 21:36:13 +0100 Subject: [PATCH 21/98] Move lock to API call to fix race condition --- app/controllers/vote.js | 15 +++++++++++++-- app/models/election.js | 12 ------------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app/controllers/vote.js b/app/controllers/vote.js index f382c286..ddd783a9 100644 --- a/app/controllers/vote.js +++ b/app/controllers/vote.js @@ -2,8 +2,14 @@ const Election = require('../models/election'); const Vote = require('../models/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], {}); + 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'); @@ -13,8 +19,10 @@ exports.create = async (req, res) => { throw new errors.InvalidPayloadError('priorities'); } + // 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((election) => { + .then(async (election) => { // Election does not exist if (!election) throw new errors.NotFoundError('election'); @@ -30,8 +38,11 @@ exports.create = async (req, res) => { 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 election.addVote(req.user, priorities); + return vote; }) .then((vote) => res.json(vote)); }; diff --git a/app/models/election.js b/app/models/election.js index a33b8cbf..d08d9a6e 100644 --- a/app/models/election.js +++ b/app/models/election.js @@ -4,14 +4,7 @@ const errors = require('../errors'); const mongoose = require('mongoose'); const Vote = require('./vote'); const Schema = mongoose.Schema; - const stv = require('../stv/stv.js'); - -const env = require('../../env'); -const redisClient = require('redis').createClient(6379, env.REDIS_URL); -const Redlock = require('redlock'); -const redlock = new Redlock([redisClient], {}); - const crypto = require('crypto'); const hasVotedSchema = new Schema({ @@ -112,16 +105,13 @@ electionSchema.methods.addVote = async function (user, priorities) { if (user.admin) throw new errors.AdminVotingError(); if (user.moderator) throw new errors.ModeratorVotingError(); - const lock = await redlock.lock('vote:' + user.username, 2000); if (!this.active) { - await lock.unlock(); throw new errors.InactiveElectionError(); } const votedUsers = this.hasVotedUsers.toObject(); const hasVoted = _.find(votedUsers, { user: user._id }); if (hasVoted) { - await lock.unlock(); throw new errors.AlreadyVotedError(); } @@ -141,8 +131,6 @@ electionSchema.methods.addVote = async function (user, priorities) { await this.save(); - await lock.unlock(); - return savedVote; }; From de1931984d6d75cac57909e29751718d684cfeb3 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Thu, 10 Dec 2020 22:09:43 +0100 Subject: [PATCH 22/98] Remove chai subset from sum test --- test/api/vote.test.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/test/api/vote.test.js b/test/api/vote.test.js index bb211554..cf1cdc72 100644 --- a/test/api/vote.test.js +++ b/test/api/vote.test.js @@ -8,10 +8,8 @@ const Election = require('../../app/models/election'); const Vote = require('../../app/models/vote'); const { test404, testAdminResource } = require('./helpers'); const { createUsers } = require('../helpers'); -const chaiSubset = require('chai-subset'); const should = chai.should(); -chai.use(chaiSubset); describe('Vote API', () => { const activeElectionData = { @@ -359,12 +357,7 @@ describe('Vote API', () => { .expect(200) .expect('Content-Type', /json/); - body.should.containSubset({ - thr: 1, - result: { - status: 'RESOLVED', - }, - }); + body.result.status.should.equal('RESOLVED'); }); it('should not be possible to get votes on an active election', async function () { From 50d5f8fc215546d2b7a1da6f6412dafeee79ef12 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Thu, 10 Dec 2020 22:38:49 +0100 Subject: [PATCH 23/98] Allow timeout to be 5 sec for CLI tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c8fa3ff..f449e629 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "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", + "mocha": "NODE_ENV=test MONGO_URL=${MONGO_URL:-'mongodb://localhost:27017/vote-test'} nyc mocha test/**/*.test.js --exit --timeout 5000", "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" From 8a7af15a0ac537f593393d1f2214324fe3324736 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 12 Dec 2020 18:11:27 +0100 Subject: [PATCH 24/98] Remove extra hasVoted schema, and use User directly --- app/controllers/election.js | 4 +--- app/models/election.js | 19 ++++++++----------- test/api/election.test.js | 4 +--- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/app/controllers/election.js b/app/controllers/election.js index 439cae1b..d43ad830 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -17,9 +17,7 @@ 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() diff --git a/app/models/election.js b/app/models/election.js index d08d9a6e..8e358362 100644 --- a/app/models/election.js +++ b/app/models/election.js @@ -7,13 +7,6 @@ const Schema = mongoose.Schema; const stv = require('../stv/stv.js'); const crypto = require('crypto'); -const hasVotedSchema = new Schema({ - user: { - type: Schema.Types.ObjectId, - ref: 'User', - }, -}); - const electionSchema = new Schema({ title: { type: String, @@ -27,7 +20,12 @@ const electionSchema = new Schema({ type: Boolean, default: false, }, - hasVotedUsers: [hasVotedSchema], + hasVotedUsers: [ + { + type: Schema.Types.ObjectId, + ref: 'User', + }, + ], alternatives: [ { type: Schema.Types.ObjectId, @@ -46,7 +44,6 @@ const electionSchema = new Schema({ ], }); -// TODO electionSchema.pre('remove', function (next) { // Use mongoose.model getter to avoid circular dependencies mongoose @@ -109,7 +106,7 @@ electionSchema.methods.addVote = async function (user, priorities) { throw new errors.InactiveElectionError(); } const votedUsers = this.hasVotedUsers.toObject(); - const hasVoted = _.find(votedUsers, { user: user._id }); + const hasVoted = _.find(votedUsers, { _id: user._id }); if (hasVoted) { throw new errors.AlreadyVotedError(); @@ -123,7 +120,7 @@ electionSchema.methods.addVote = async function (user, priorities) { priorities: priorities, }); - this.hasVotedUsers.push({ user: user._id }); + this.hasVotedUsers.push(user._id); await this.save(); const savedVote = await vote.save(); diff --git a/test/api/election.test.js b/test/api/election.test.js index 8dd0fc2c..592984ce 100644 --- a/test/api/election.test.js +++ b/test/api/election.test.js @@ -374,9 +374,7 @@ describe('Election API', () => { it('should filter out elections the user has voted on', async function () { passportStub.login(this.user.username); - this.activeElection.hasVotedUsers.push({ - user: this.user.id, - }); + this.activeElection.hasVotedUsers.push(this.user._id); await this.activeElection.save(); const { body } = await request(app) From 0fed94d1846dae84446b27e62a3acf233e96f0ac Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 15 Dec 2020 19:08:22 +0100 Subject: [PATCH 25/98] Implement useStrict in Election models/controller, and add tests --- app/controllers/election.js | 2 + app/models/election.js | 14 +++- test/api/election.test.js | 130 ++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/app/controllers/election.js b/app/controllers/election.js index d43ad830..b8194213 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -29,6 +29,8 @@ 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; diff --git a/app/models/election.js b/app/models/election.js index 8e358362..73f26795 100644 --- a/app/models/election.js +++ b/app/models/election.js @@ -35,6 +35,7 @@ const electionSchema = new Schema({ seats: { type: Number, default: 1, + min: [1, 'An election should have at least one seat'], }, votes: [ { @@ -42,6 +43,16 @@ const electionSchema = new Schema({ 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', + }, + }, }); electionSchema.pre('remove', function (next) { @@ -84,7 +95,8 @@ electionSchema.methods.elect = async function () { return stv.calculateWinnerUsingSTV( cleanElection.votes, cleanElection.alternatives, - cleanElection.seats + cleanElection.seats, + cleanElection.useStrict ); }; diff --git a/test/api/election.test.js b/test/api/election.test.js index 592984ce..2a5f8bad 100644 --- a/test/api/election.test.js +++ b/test/api/election.test.js @@ -140,6 +140,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'); From 3ac3e78976c06ee748f9d27a44f1c81ff88ecc21 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 15 Dec 2020 19:48:43 +0100 Subject: [PATCH 26/98] Small test improvements --- app/controllers/vote.js | 6 +++--- test/api/vote.test.js | 11 ++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/controllers/vote.js b/app/controllers/vote.js index ddd783a9..bff2cfbd 100644 --- a/app/controllers/vote.js +++ b/app/controllers/vote.js @@ -44,7 +44,7 @@ exports.create = async (req, res) => { return vote; }) - .then((vote) => res.json(vote)); + .then((vote) => res.status(201).json(vote)); }; exports.retrieve = async (req, res) => { @@ -56,8 +56,8 @@ exports.retrieve = async (req, res) => { const vote = await Vote.findOne({ hash: hash }) .populate('priorities') - .populate('election'); + .populate('election', 'title _id'); if (!vote) throw new errors.NotFoundError('vote'); - res.json(vote); + res.status(200).json(vote); }; diff --git a/test/api/vote.test.js b/test/api/vote.test.js index cf1cdc72..bd21b8f4 100644 --- a/test/api/vote.test.js +++ b/test/api/vote.test.js @@ -189,6 +189,7 @@ describe('Vote API', () => { const { body: vote } = await request(app) .post('/api/vote') .send(votePayload(this.activeElection, [])) + .expect(201) .expect('Content-Type', /json/); should.exist(vote.hash); @@ -202,6 +203,7 @@ describe('Vote API', () => { 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); @@ -221,6 +223,7 @@ describe('Vote API', () => { this.otherActiveAlternative, ]) ) + .expect(201) .expect('Content-Type', /json/); should.exist(vote.hash); @@ -270,7 +273,7 @@ describe('Vote API', () => { passportStub.logout(); const { body: error } = await request(app) .post('/api/vote') - .send(votePayload({}, [])) + .send(votePayload(this.activeElection, [this.activeAlternative])) .expect(401) .expect('Content-Type', /json/); error.status.should.equal(401); @@ -323,6 +326,7 @@ describe('Vote API', () => { 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') @@ -331,6 +335,11 @@ describe('Vote API', () => { .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 () => { From 073713369d399beb75beb7ee4bfacbada8b5890b Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Tue, 17 Nov 2020 21:27:58 +0100 Subject: [PATCH 27/98] Admin UI for setting amount of seats --- app/views/partials/admin/createElection.pug | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/views/partials/admin/createElection.pug b/app/views/partials/admin/createElection.pug index 3491cca1..e1789506 100644 --- a/app/views/partials/admin/createElection.pug +++ b/app/views/partials/admin/createElection.pug @@ -24,6 +24,22 @@ 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' + ) + p.text-danger(ng-show='createElectionForm.seats.$invalid') + | Antall plasser er ikke gyldig + br + | Må være minst 1 og maks {{ election.alternatives.length }} + .alternatives.admin label Alternativer a.new-alternative(ng-click='addAlternative()') From d599f022c23078b51dfb235f9cabb041c59824fe Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Tue, 12 Jan 2021 20:56:25 +0100 Subject: [PATCH 28/98] Add checkbox to enable `election.useStrict` for create view --- app/views/partials/admin/createElection.pug | 19 ++++++++++++++++++- client/styles/admin.styl | 4 ++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/views/partials/admin/createElection.pug b/app/views/partials/admin/createElection.pug index e1789506..66501cd1 100644 --- a/app/views/partials/admin/createElection.pug +++ b/app/views/partials/admin/createElection.pug @@ -33,12 +33,15 @@ required='required', ng-model='election.seats', ng-min='1', - ng-max='election.alternatives.length' + 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 @@ -67,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/client/styles/admin.styl b/client/styles/admin.styl index 81cd178a..5401d420 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 From ce166e0f39189f85c7fe53e462a603976ade9437 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 28 Nov 2020 18:53:28 +0100 Subject: [PATCH 29/98] Add the chai-subset to resolve deep trees --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index f449e629..4cf1599b 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@prettier/plugin-pug": "1.10.1", "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", diff --git a/yarn.lock b/yarn.lock index 61fa3d7f..854d2276 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1655,6 +1655,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" From 7aa132fd213a97668cdf95cc85dc027cd6c8552a Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 28 Nov 2020 17:30:34 +0100 Subject: [PATCH 30/98] Add dataset converter, datasets and tests for the STV algorithm --- test/stv/datasets/dataset1.js | 19 ++ test/stv/datasets/dataset2.js | 31 +++ test/stv/datasets/dataset3.js | 117 +++++++++ test/stv/datasets/dataset4.js | 31 +++ test/stv/datasets/dataset5.js | 86 +++++++ test/stv/datasets/dataset6.js | 57 +++++ test/stv/datasets/index.js | 8 + test/stv/stv.test.js | 433 ++++++++++++++++++++++++++++++++++ 8 files changed, 782 insertions(+) create mode 100644 test/stv/datasets/dataset1.js create mode 100644 test/stv/datasets/dataset2.js create mode 100644 test/stv/datasets/dataset3.js create mode 100644 test/stv/datasets/dataset4.js create mode 100644 test/stv/datasets/dataset5.js create mode 100644 test/stv/datasets/dataset6.js create mode 100644 test/stv/datasets/index.js create mode 100644 test/stv/stv.test.js 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/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..e9c676f0 --- /dev/null +++ b/test/stv/datasets/dataset6.js @@ -0,0 +1,57 @@ +// 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 21 votes. + * + * This will give a quota of Floor(21/(2+1)) + 1 which is 8 + * + * ============================================================================= + * + * When adding up the multiple fractions creaded 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 point in the iterations. + */ diff --git a/test/stv/datasets/index.js b/test/stv/datasets/index.js new file mode 100644 index 00000000..0cd273fd --- /dev/null +++ b/test/stv/datasets/index.js @@ -0,0 +1,8 @@ +module.exports = { + dataset1: require('./dataset1'), + dataset2: require('./dataset2'), + dataset3: require('./dataset3'), + dataset4: require('./dataset4'), + dataset5: require('./dataset5'), + dataset6: require('./dataset6'), +}; diff --git a/test/stv/stv.test.js b/test/stv/stv.test.js new file mode 100644 index 00000000..f69d683b --- /dev/null +++ b/test/stv/stv.test.js @@ -0,0 +1,433 @@ +const mongoose = require('mongoose'); +const Alternative = require('../../app/models/alternative'); +const Election = require('../../app/models/election'); +const Vote = require('../../app/models/vote'); +const crypto = require('crypto'); +const chai = require('chai'); +const chaiSubset = require('chai-subset'); +const dataset = require('./datasets'); + +const should = chai.should(); +chai.use(chaiSubset); + +describe('STV Logic', () => { + 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, + }); + // 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; + }; + + 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, + 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, + 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', + alternatives: [{ 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', + alternatives: [{ description: 'Hamburger' }], + minScore: 3, + }, + { + action: 'ITERATION', + iteration: 5, + counts: { + Strawberry: 5, + }, + }, + { + action: 'ELIMINATE', + alternatives: [{ 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, + 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', + alternatives: [{ 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', + alternatives: [{ 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', + alternatives: [{ 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', + alternatives: [{ description: 'COHEN' }], + minScore: 72, + }, + { + action: 'ITERATION', + iteration: 8, + counts: { + VINE: 104, + }, + }, + { + action: 'ELIMINATE', + alternatives: [{ description: 'VINE' }], + minScore: 104, + }, + ], + }); + }); + + it('should find 1 winner, and resolve for dataset 4', async function () { + const election = await prepareElection(dataset.dataset4); + const electionResult = await election.elect(); + + electionResult.should.containSubset({ + thr: 103, + result: { + status: 'RESOLVED', + winners: [{ description: 'Erna Solberg' }], + }, + log: [ + { + action: 'ITERATION', + iteration: 1, + winners: [], + counts: { + 'Erna Solberg': 88, + 'Siv Jensen': 44, + 'Bent Høye': 72, + }, + }, + { + action: 'ELIMINATE', + alternatives: [{ description: 'Siv Jensen' }], + minScore: 44, + }, + { + action: 'ITERATION', + iteration: 2, + winners: [], + counts: { + 'Erna Solberg': 132, + 'Bent Høye': 72, + }, + }, + { + action: 'WIN', + alternative: { description: 'Erna Solberg' }, + voteCount: 132, + }, + ], + }); + }); + + it('should calculate floating points correctly for dataset5', async function () { + const election = await prepareElection(dataset.dataset5); + const electionResult = await election.elect(); + //TODO + }); + + it('should calculate floating points correctly for dataset6', async function () { + const election = await prepareElection(dataset.dataset6); + const electionResult = await election.elect(); + //TODO + }); +}); From 0d22362c679ddc22daba034353ab3423fc555a5a Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Fri, 11 Dec 2020 18:12:35 +0100 Subject: [PATCH 31/98] Write assertions for dataset 5 and 6 --- test/stv/datasets/dataset6.js | 6 +- test/stv/stv.test.js | 165 +++++++++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/test/stv/datasets/dataset6.js b/test/stv/datasets/dataset6.js index e9c676f0..0255d72d 100644 --- a/test/stv/datasets/dataset6.js +++ b/test/stv/datasets/dataset6.js @@ -43,9 +43,9 @@ module.exports = { * * ============================================================================= * - * So with the test above there are 2 seats and a total of 21 votes. + * So with the test above there are 2 seats and a total of 210 votes. * - * This will give a quota of Floor(21/(2+1)) + 1 which is 8 + * This will give a quota of Floor(210/(2+1)) + 1 which is 71 * * ============================================================================= * @@ -53,5 +53,5 @@ module.exports = { * 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 point in the iterations. + * equal at this iteration. */ diff --git a/test/stv/stv.test.js b/test/stv/stv.test.js index f69d683b..f448dd21 100644 --- a/test/stv/stv.test.js +++ b/test/stv/stv.test.js @@ -1,4 +1,3 @@ -const mongoose = require('mongoose'); const Alternative = require('../../app/models/alternative'); const Election = require('../../app/models/election'); const Vote = require('../../app/models/vote'); @@ -7,7 +6,6 @@ const chai = require('chai'); const chaiSubset = require('chai-subset'); const dataset = require('./datasets'); -const should = chai.should(); chai.use(chaiSubset); describe('STV Logic', () => { @@ -422,12 +420,171 @@ describe('STV Logic', () => { it('should calculate floating points correctly for dataset5', async function () { const election = await prepareElection(dataset.dataset5); const electionResult = await election.elect(); - //TODO + electionResult.should.containSubset({ + thr: 8, + 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', + alternatives: [{ description: 'D' }], + minScore: 3.3333, + }, + { + action: 'ITERATION', + iteration: 3, + winners: [{ description: 'A' }], + counts: { + B: 7.6667, + C: 4.3333, + }, + }, + { + action: 'ELIMINATE', + alternatives: [{ 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(); - //TODO + electionResult.should.containSubset({ + thr: 71, + 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: 'ELIMINATE', + 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', + alternatives: [{ 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', + alternatives: [{ description: 'E' }], + minScore: 3.5889, + }, + { + action: 'ITERATION', + iteration: 5, + winners: [{ description: 'A' }], + counts: { + B: 46.3333, + C: 46.3333, + D: 46.3333, + }, + }, + { + action: 'ELIMINATE', + alternatives: [ + { description: 'B' }, + { description: 'C' }, + { description: 'D' }, + ], + minScore: 46.3333, + }, + ], + }); }); }); From 6459546f241a0fffacf1dba1dc315f420ade2207 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Fri, 11 Dec 2020 22:06:46 +0100 Subject: [PATCH 32/98] Change dataset 6 tests to reflect backward tracking in the STV algorithm --- test/stv/datasets/dataset6.js | 17 ++++++++- test/stv/stv.test.js | 66 ++++++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/test/stv/datasets/dataset6.js b/test/stv/datasets/dataset6.js index 0255d72d..3e89e9c0 100644 --- a/test/stv/datasets/dataset6.js +++ b/test/stv/datasets/dataset6.js @@ -49,9 +49,24 @@ module.exports = { * * ============================================================================= * - * When adding up the multiple fractions creaded above the result should be that the + * 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/stv.test.js b/test/stv/stv.test.js index f448dd21..8898bd2d 100644 --- a/test/stv/stv.test.js +++ b/test/stv/stv.test.js @@ -153,7 +153,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'Pear' }], + alternative: { description: 'Pear' }, minScore: 2, }, { @@ -182,7 +182,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'Hamburger' }], + alternative: { description: 'Hamburger' }, minScore: 3, }, { @@ -194,7 +194,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'Strawberry' }], + alternative: { description: 'Strawberry' }, minScore: 5, }, { @@ -260,7 +260,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'PEARSON' }], + alternative: { description: 'PEARSON' }, minScore: 34, }, { @@ -279,7 +279,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'LENNON' }], + alternative: { description: 'LENNON' }, minScore: 58, }, { @@ -314,7 +314,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'WILCOCKS' }], + alternative: { description: 'WILCOCKS' }, minScore: 60, }, { @@ -354,7 +354,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'COHEN' }], + alternative: { description: 'COHEN' }, minScore: 72, }, { @@ -366,7 +366,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'VINE' }], + alternative: { description: 'VINE' }, minScore: 104, }, ], @@ -396,7 +396,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'Siv Jensen' }], + alternative: { description: 'Siv Jensen' }, minScore: 44, }, { @@ -455,7 +455,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'D' }], + alternative: { description: 'D' }, minScore: 3.3333, }, { @@ -469,7 +469,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'C' }], + alternative: { description: 'C' }, minScore: 4.3333, }, { @@ -529,7 +529,12 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'G' }, { description: 'H' }], + alternative: { description: 'G' }, + minScore: 0, + }, + { + action: 'ELIMINATE', + alternative: { description: 'H' }, minScore: 0, }, { @@ -546,7 +551,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'F' }], + alternative: { description: 'F' }, minScore: 2.7444, }, { @@ -562,7 +567,7 @@ describe('STV Logic', () => { }, { action: 'ELIMINATE', - alternatives: [{ description: 'E' }], + alternative: { description: 'E' }, minScore: 3.5889, }, { @@ -575,14 +580,35 @@ describe('STV Logic', () => { 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', - alternatives: [ - { description: 'B' }, - { description: 'C' }, - { description: 'D' }, - ], - minScore: 46.3333, + 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', }, ], }); From fa8e298f0e508a92d84c164e3725e0fd63f580b1 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 12 Dec 2020 14:19:46 +0100 Subject: [PATCH 33/98] Eliminate G and H at random order --- test/stv/stv.test.js | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/test/stv/stv.test.js b/test/stv/stv.test.js index 8898bd2d..b7956660 100644 --- a/test/stv/stv.test.js +++ b/test/stv/stv.test.js @@ -528,18 +528,35 @@ describe('STV Logic', () => { }, }, { - action: 'ELIMINATE', - alternative: { description: 'G' }, + action: 'TIE', + description: + 'There are 2 candidates with a score of 0 at iteration 2', + }, + { + action: 'RANDOMELIMINATE', + // We don't know if G or H will be eliminated here 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: 'H' }, + // We don't know if G or H will be eliminated here minScore: 0, }, { action: 'ITERATION', - iteration: 3, + iteration: 4, winners: [{ description: 'A' }], counts: { B: 46.3333, @@ -556,7 +573,7 @@ describe('STV Logic', () => { }, { action: 'ITERATION', - iteration: 4, + iteration: 5, winners: [{ description: 'A' }], counts: { B: 46.3333, @@ -572,7 +589,7 @@ describe('STV Logic', () => { }, { action: 'ITERATION', - iteration: 5, + iteration: 6, winners: [{ description: 'A' }], counts: { B: 46.3333, @@ -583,7 +600,7 @@ describe('STV Logic', () => { { action: 'TIE', description: - 'There are 3 candidates with a score of 46.3333 at iteration 5', + 'There are 3 candidates with a score of 46.3333 at iteration 6', }, // Egde case iteration. See the dataset for explanation { @@ -593,7 +610,7 @@ describe('STV Logic', () => { }, { action: 'ITERATION', - iteration: 6, + iteration: 7, winners: [{ description: 'A' }], counts: { B: 46.3333, @@ -603,7 +620,7 @@ describe('STV Logic', () => { { action: 'TIE', description: - 'There are 2 candidates with a score of 46.3333 at iteration 6', + 'There are 2 candidates with a score of 46.3333 at iteration 7', }, { action: 'TIE', From d39419261beac9d4fdca4b3d4ed59c8903dfcc8e Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 12 Dec 2020 16:36:53 +0100 Subject: [PATCH 34/98] Double eliminations on tie --- test/stv/stv.test.js | 43 ++++++++++++++++++------------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/test/stv/stv.test.js b/test/stv/stv.test.js index b7956660..f95010b7 100644 --- a/test/stv/stv.test.js +++ b/test/stv/stv.test.js @@ -533,30 +533,18 @@ describe('STV Logic', () => { 'There are 2 candidates with a score of 0 at iteration 2', }, { - action: 'RANDOMELIMINATE', - // We don't know if G or H will be eliminated here + action: 'TIEELIMINATE', + alternative: { description: 'G' }, 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', - // We don't know if G or H will be eliminated here + action: 'TIEELIMINATE', + alternative: { description: 'H' }, minScore: 0, }, { action: 'ITERATION', - iteration: 4, + iteration: 3, winners: [{ description: 'A' }], counts: { B: 46.3333, @@ -573,7 +561,7 @@ describe('STV Logic', () => { }, { action: 'ITERATION', - iteration: 5, + iteration: 4, winners: [{ description: 'A' }], counts: { B: 46.3333, @@ -589,7 +577,7 @@ describe('STV Logic', () => { }, { action: 'ITERATION', - iteration: 6, + iteration: 5, winners: [{ description: 'A' }], counts: { B: 46.3333, @@ -600,7 +588,7 @@ describe('STV Logic', () => { { action: 'TIE', description: - 'There are 3 candidates with a score of 46.3333 at iteration 6', + 'There are 3 candidates with a score of 46.3333 at iteration 5', }, // Egde case iteration. See the dataset for explanation { @@ -610,7 +598,7 @@ describe('STV Logic', () => { }, { action: 'ITERATION', - iteration: 7, + iteration: 6, winners: [{ description: 'A' }], counts: { B: 46.3333, @@ -620,12 +608,17 @@ describe('STV Logic', () => { { action: 'TIE', description: - 'There are 2 candidates with a score of 46.3333 at iteration 7', + '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: 'TIEELIMINATE', + alternative: { description: 'B' }, + minScore: 46.3333, + }, + { + action: 'TIEELIMINATE', + alternative: { description: 'C' }, + minScore: 46.3333, }, ], }); From 4ab44f6988a080d46fcca0ac5cdae81524923acb Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 12 Dec 2020 19:28:21 +0100 Subject: [PATCH 35/98] Use MULTI_TIE_ELIMINATIONS --- test/stv/stv.test.js | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/test/stv/stv.test.js b/test/stv/stv.test.js index f95010b7..099ca689 100644 --- a/test/stv/stv.test.js +++ b/test/stv/stv.test.js @@ -533,13 +533,8 @@ describe('STV Logic', () => { 'There are 2 candidates with a score of 0 at iteration 2', }, { - action: 'TIEELIMINATE', - alternative: { description: 'G' }, - minScore: 0, - }, - { - action: 'TIEELIMINATE', - alternative: { description: 'H' }, + action: 'MULTI_TIE_ELIMINATIONS', + alternatives: [{ description: 'G' }, { description: 'H' }], minScore: 0, }, { @@ -611,14 +606,15 @@ describe('STV Logic', () => { 'There are 2 candidates with a score of 46.3333 at iteration 6', }, { - action: 'TIEELIMINATE', - alternative: { description: 'B' }, - minScore: 46.3333, + action: 'MULTI_TIE_ELIMINATIONS', + alternatives: [{ description: 'B' }, { description: 'C' }], + minScore: 0, }, { - action: 'TIEELIMINATE', - alternative: { description: 'C' }, - minScore: 46.3333, + action: 'ITERATION', + iteration: 7, + winners: [{ description: 'A' }], + counts: {}, }, ], }); From c34d41c9059f6a32ce0367a20df3e8db9bb28e20 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 14 Dec 2020 12:50:09 +0100 Subject: [PATCH 36/98] Fix tests --- test/stv/stv.test.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/stv/stv.test.js b/test/stv/stv.test.js index 099ca689..e87a926a 100644 --- a/test/stv/stv.test.js +++ b/test/stv/stv.test.js @@ -532,6 +532,11 @@ describe('STV Logic', () => { 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' }], @@ -605,10 +610,15 @@ describe('STV Logic', () => { 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: 0, + minScore: 46.3333, }, { action: 'ITERATION', From c101cb50af619755cb330499eaca5869cccd70c4 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 14 Dec 2020 18:14:18 +0100 Subject: [PATCH 37/98] Add OpaVote testcase and dataset --- package.json | 2 +- test/stv/datasets/datasetOpaVote.js | 332 ++++++++++++++++++++++++++++ test/stv/datasets/index.js | 1 + test/stv/stv.test.js | 95 ++++++++ 4 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 test/stv/datasets/datasetOpaVote.js diff --git a/package.json b/package.json index 4cf1599b..17dcc223 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "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 5000", + "mocha": "NODE_ENV=test MONGO_URL=${MONGO_URL:-'mongodb://localhost:27017/vote-test'} nyc mocha test/**/*.test.js --exit --timeout 10000", "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" 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 index 0cd273fd..c0db0556 100644 --- a/test/stv/datasets/index.js +++ b/test/stv/datasets/index.js @@ -1,4 +1,5 @@ module.exports = { + datasetOpaVote: require('./datasetOpaVote'), dataset1: require('./dataset1'), dataset2: require('./dataset2'), dataset3: require('./dataset3'), diff --git a/test/stv/stv.test.js b/test/stv/stv.test.js index e87a926a..b25e65a9 100644 --- a/test/stv/stv.test.js +++ b/test/stv/stv.test.js @@ -74,6 +74,8 @@ describe('STV Logic', () => { electionResult.should.containSubset({ thr: 34, + seats: 2, + voteCount: 100, result: { status: 'RESOLVED', winners: [{ description: 'Andrea' }, { description: 'Carter' }], @@ -118,6 +120,8 @@ describe('STV Logic', () => { electionResult.should.containSubset({ thr: 6, + seats: 3, + voteCount: 20, result: { status: 'UNRESOLVED', winners: [{ description: 'Chocolate' }, { description: 'Orange' }], @@ -212,6 +216,8 @@ describe('STV Logic', () => { electionResult.should.containSubset({ thr: 108, + seats: 5, + voteCount: 647, result: { status: 'UNRESOLVED', winners: [ @@ -379,6 +385,8 @@ describe('STV Logic', () => { electionResult.should.containSubset({ thr: 103, + seats: 1, + voteCount: 204, result: { status: 'RESOLVED', winners: [{ description: 'Erna Solberg' }], @@ -422,6 +430,8 @@ describe('STV Logic', () => { const electionResult = await election.elect(); electionResult.should.containSubset({ thr: 8, + seats: 2, + voteCount: 23, result: { status: 'RESOLVED', winners: [{ description: 'A' }, { description: 'B' }], @@ -494,6 +504,8 @@ describe('STV Logic', () => { const electionResult = await election.elect(); electionResult.should.containSubset({ thr: 71, + seats: 2, + voteCount: 210, result: { status: 'UNRESOLVED', winners: [{ description: 'A' }], @@ -629,4 +641,87 @@ describe('STV Logic', () => { ], }); }); + + it('should calculate the result correctly for the OpaVote datase', async function () { + const election = await prepareElection(dataset.datasetOpaVote); + const electionResult = await election.elect(); + electionResult.should.containSubset({ + thr: 3750, + seats: 2, + voteCount: 11248, + 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, + }, + ], + }); + }); }); From ae2e3035fbe4f7af2717e6773a96b778adb474a9 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 15 Dec 2020 17:34:12 +0100 Subject: [PATCH 38/98] Implement STV tests with useStrict --- test/stv/datasets/dataset10.js | 15 ++++++++ test/stv/datasets/dataset7.js | 15 ++++++++ test/stv/datasets/dataset8.js | 15 ++++++++ test/stv/datasets/dataset9.js | 15 ++++++++ test/stv/datasets/index.js | 4 +++ test/stv/stv.test.js | 64 ++++++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+) create mode 100644 test/stv/datasets/dataset10.js create mode 100644 test/stv/datasets/dataset7.js create mode 100644 test/stv/datasets/dataset8.js create mode 100644 test/stv/datasets/dataset9.js 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/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/index.js b/test/stv/datasets/index.js index c0db0556..46f1d983 100644 --- a/test/stv/datasets/index.js +++ b/test/stv/datasets/index.js @@ -6,4 +6,8 @@ module.exports = { dataset4: require('./dataset4'), dataset5: require('./dataset5'), dataset6: require('./dataset6'), + dataset7: require('./dataset7'), + dataset8: require('./dataset8'), + dataset9: require('./dataset9'), + dataset10: require('./dataset10'), }; diff --git a/test/stv/stv.test.js b/test/stv/stv.test.js index b25e65a9..119cfac3 100644 --- a/test/stv/stv.test.js +++ b/test/stv/stv.test.js @@ -29,6 +29,7 @@ describe('STV Logic', () => { 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( @@ -76,6 +77,7 @@ describe('STV Logic', () => { thr: 34, seats: 2, voteCount: 100, + useStrict: false, result: { status: 'RESOLVED', winners: [{ description: 'Andrea' }, { description: 'Carter' }], @@ -122,6 +124,7 @@ describe('STV Logic', () => { thr: 6, seats: 3, voteCount: 20, + useStrict: false, result: { status: 'UNRESOLVED', winners: [{ description: 'Chocolate' }, { description: 'Orange' }], @@ -218,6 +221,7 @@ describe('STV Logic', () => { thr: 108, seats: 5, voteCount: 647, + useStrict: false, result: { status: 'UNRESOLVED', winners: [ @@ -387,6 +391,7 @@ describe('STV Logic', () => { thr: 103, seats: 1, voteCount: 204, + useStrict: false, result: { status: 'RESOLVED', winners: [{ description: 'Erna Solberg' }], @@ -432,6 +437,7 @@ describe('STV Logic', () => { thr: 8, seats: 2, voteCount: 23, + useStrict: false, result: { status: 'RESOLVED', winners: [{ description: 'A' }, { description: 'B' }], @@ -506,6 +512,7 @@ describe('STV Logic', () => { thr: 71, seats: 2, voteCount: 210, + useStrict: false, result: { status: 'UNRESOLVED', winners: [{ description: 'A' }], @@ -649,6 +656,7 @@ describe('STV Logic', () => { thr: 3750, seats: 2, voteCount: 11248, + useStrict: false, result: { status: 'RESOLVED', winners: [{ description: 'Steve' }, { description: 'Bill' }], @@ -724,4 +732,60 @@ describe('STV 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' }], + }, + }); + }); }); From 46c3845279501970f4a2db2e8073945886d18317 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 15 Dec 2020 19:59:56 +0100 Subject: [PATCH 39/98] OpaVote needs 30000ms to run on CI --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 17dcc223..11d040b9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "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 10000", + "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" From 9dd16bad5a2dab800da2b2422a25cca0785fd2b7 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 15 Dec 2020 20:23:10 +0100 Subject: [PATCH 40/98] Split STV tests intp multiple files to make editors less laggy --- test/helpers.js | 61 +++ test/stv/float.test.js | 227 +++++++++++ test/stv/normal.test.js | 322 ++++++++++++++++ test/stv/opaVote.test.js | 92 +++++ test/stv/strict.test.js | 65 ++++ test/stv/stv.test.js | 791 --------------------------------------- 6 files changed, 767 insertions(+), 791 deletions(-) create mode 100644 test/stv/float.test.js create mode 100644 test/stv/normal.test.js create mode 100644 test/stv/opaVote.test.js create mode 100644 test/stv/strict.test.js delete mode 100644 test/stv/stv.test.js diff --git a/test/helpers.js b/test/helpers.js index 1d494c57..a834055f 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -4,6 +4,7 @@ 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 crypto = require('crypto'); exports.dropDatabase = () => mongoose.connection.dropDatabase().then(() => mongoose.disconnect()); @@ -37,3 +38,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/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/test/stv/stv.test.js b/test/stv/stv.test.js deleted file mode 100644 index 119cfac3..00000000 --- a/test/stv/stv.test.js +++ /dev/null @@ -1,791 +0,0 @@ -const Alternative = require('../../app/models/alternative'); -const Election = require('../../app/models/election'); -const Vote = require('../../app/models/vote'); -const crypto = require('crypto'); -const chai = require('chai'); -const chaiSubset = require('chai-subset'); -const dataset = require('./datasets'); - -chai.use(chaiSubset); - -describe('STV Logic', () => { - 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; - }; - - 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, - }, - ], - }); - }); - - it('should find 1 winner, and resolve for dataset 4', async function () { - const election = await prepareElection(dataset.dataset4); - const electionResult = await election.elect(); - - electionResult.should.containSubset({ - thr: 103, - seats: 1, - voteCount: 204, - useStrict: false, - result: { - status: 'RESOLVED', - winners: [{ description: 'Erna Solberg' }], - }, - log: [ - { - action: 'ITERATION', - iteration: 1, - winners: [], - counts: { - 'Erna Solberg': 88, - 'Siv Jensen': 44, - 'Bent Høye': 72, - }, - }, - { - action: 'ELIMINATE', - alternative: { description: 'Siv Jensen' }, - minScore: 44, - }, - { - action: 'ITERATION', - iteration: 2, - winners: [], - counts: { - 'Erna Solberg': 132, - 'Bent Høye': 72, - }, - }, - { - action: 'WIN', - alternative: { description: 'Erna Solberg' }, - voteCount: 132, - }, - ], - }); - }); - - 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: {}, - }, - ], - }); - }); - - it('should calculate the result correctly for the OpaVote datase', 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, - }, - ], - }); - }); - 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' }], - }, - }); - }); -}); From ad9d5bda7ec3275d63cf4d4614acf7f3b4d9e453 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Sat, 12 Dec 2020 21:33:36 +0100 Subject: [PATCH 41/98] Inital implementation of priority selection --- app/views/partials/election.pug | 23 +++++++++++++--- client/controllers/electionCtrl.js | 29 +++++++++++++++++---- client/directives/index.js | 3 ++- client/directives/sortableDirective.js | 36 ++++++++++++++++++++++++++ client/services/voteService.js | 4 +-- client/styles/election.styl | 27 +++++++++++++++++-- package.json | 1 + yarn.lock | 5 ++++ 8 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 client/directives/sortableDirective.js diff --git a/app/views/partials/election.pug b/app/views/partials/election.pug index e56c2fcd..32db1c8c 100644 --- a/app/views/partials/election.pug +++ b/app/views/partials/election.pug @@ -4,19 +4,36 @@ h2 {{ activeElection.title }} p {{ activeElection.description }} + .alternatives + h3 Din prioritering + p(ng-if="priorities.length == 0").helptext. + Velg et alternativ fra listen for å begynne + + ul.list-unstyled.numbered( + sortable, + sortable-on-update="updatePriority", + sortable-list="priorities", + sortable-animation='150', + sortable-delay='50' + ) + li( + ng-repeat='alternative in priorities', + ng-click='deselectAlternative(alternative)', + ) + p {{ alternative.description }} + .alternatives h3 Alternativer ul.list-unstyled li( - ng-repeat='alternative in activeElection.alternatives', + ng-repeat='alternative in getPossibleAlternatives()', ng-click='selectAlternative(alternative)', - ng-class='{"selected": isChosen(alternative)}' ) p {{ alternative.description }} confirm-vote( vote-handler='vote()', - selected-alternative='selectedAlternative' + selected-alternatives='priorities' ) div(ng-switch-default) diff --git a/client/controllers/electionCtrl.js b/client/controllers/electionCtrl.js index 4c8fddd5..a62529a0 100644 --- a/client/controllers/electionCtrl.js +++ b/client/controllers/electionCtrl.js @@ -16,7 +16,7 @@ module.exports = [ localStorageService ) { $scope.activeElection = null; - $scope.selectedAlternative = null; + $scope.priorities = []; /** * Tries to find an active election @@ -34,19 +34,38 @@ module.exports = [ getActiveElection(); socketIOService.listen('election', getActiveElection); + $scope.getPossibleAlternatives = function () { + return $scope.activeElection.alternatives.filter( + (e) => !$scope.priorities.includes(e) + ); + }; + + $scope.updatePriority = function (oldIndex, newIndex) { + const alternative = $scope.priorities.splice(oldIndex, 1)[0]; + $scope.priorities.splice(newIndex, 0, alternative); + }; + /** - * 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 {Object} alternative + */ + $scope.deselectAlternative = function (alternative) { + $scope.priorities = $scope.priorities.filter((a) => a !== alternative); }; /** * 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; @@ -86,7 +105,7 @@ module.exports = [ * @return {Boolean} */ $scope.isChosen = function (alternative) { - return alternative === $scope.selectedAlternative; + return $scope.priorities.includes(alternative); }; }, ]; diff --git a/client/directives/index.js b/client/directives/index.js index 60d8d0b1..516d84aa 100644 --- a/client/directives/index.js +++ b/client/directives/index.js @@ -2,4 +2,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..5b71bdad --- /dev/null +++ b/client/directives/sortableDirective.js @@ -0,0 +1,36 @@ +const Sortable = require('sortablejs').Sortable; + +module.exports = function () { + return { + restrict: 'A', + scope: { + sortableList: '=', + sortableAnimation: '=', + sortableOnUpdate: '=', + sortableDelay: '=', + }, + link: function (scope, elem) { + let img; + Sortable.create(elem[0], { + delay: scope.sortableDelay, + delayOnTouchOnly: true, + animation: scope.sortableAnimation, + 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.parentNode.removeChild(img); + }, + onUpdate: scope.sortableOnUpdate, + }); + }, + }; +}; 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/election.styl b/client/styles/election.styl index 7b019f26..9ed6ac06 100644 --- a/client/styles/election.styl +++ b/client/styles/election.styl @@ -4,6 +4,9 @@ h2 text-transform uppercase +.helptext + line-height 60px + .alternatives margin-bottom 30px border-top 1px solid rgba(177, 181, 188, 0.3) @@ -32,7 +35,6 @@ border-radius 2px border 1px solid transparent background-color alpha($alternative-background, 0.15) - transition all 0.5s &:hover cursor pointer @@ -54,9 +56,30 @@ text-transform uppercase margin-bottom 0 font-weight 200 - transition all 0.3s + transition color 0.3s vertical-align middle + .numbered + counter-reset item + + .sortable-drag + display none + + li + display inline-block + counter-increment item + position relative + width 90% + &: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 @media (max-width 1000px) .alternatives diff --git a/package.json b/package.json index 11d040b9..99d69a76 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "redlock": "3.1.2", "serve-favicon": "2.5.0", "socket.io": "2.2.0", + "sortablejs": "1.12.0", "style-loader": "0.23.1", "stylus": "0.54.5", "stylus-loader": "3.0.2", diff --git a/yarn.lock b/yarn.lock index 854d2276..ad3ee379 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7123,6 +7123,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" From 962f45b8d06d23c8a6bf02efe7768c7c2af00081 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Sun, 13 Dec 2020 15:53:27 +0100 Subject: [PATCH 42/98] Add styling and iron out the UX for user voting --- app/views/partials/election.pug | 36 +++++----- client/controllers/electionCtrl.js | 10 ++- client/directives/confirmVoteDirective.js | 10 +-- client/directives/sortableDirective.js | 13 +++- client/styles/election.styl | 84 +++++++++++++++++------ 5 files changed, 105 insertions(+), 48 deletions(-) diff --git a/app/views/partials/election.pug b/app/views/partials/election.pug index 32db1c8c..01915084 100644 --- a/app/views/partials/election.pug +++ b/app/views/partials/election.pug @@ -6,35 +6,35 @@ .alternatives h3 Din prioritering - p(ng-if="priorities.length == 0").helptext. - Velg et alternativ fra listen for å begynne + 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='150', - sortable-delay='50' - ) - li( - ng-repeat='alternative in priorities', - ng-click='deselectAlternative(alternative)', - ) - p {{ alternative.description }} + 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 + i.fa.fa-bars.drag + p {{ alternative.description }} + .close(ng-click='deselectAlternative(alternative._id)') + i.fa.fa-close .alternatives h3 Alternativer ul.list-unstyled li( ng-repeat='alternative in getPossibleAlternatives()', - ng-click='selectAlternative(alternative)', + ng-click='selectAlternative(alternative)' ) - p {{ alternative.description }} + .content + p {{ alternative.description }} - confirm-vote( - vote-handler='vote()', - selected-alternatives='priorities' - ) + confirm-vote(vote-handler='vote()', selected-alternatives='priorities') div(ng-switch-default) h2. diff --git a/client/controllers/electionCtrl.js b/client/controllers/electionCtrl.js index a62529a0..f7729c1f 100644 --- a/client/controllers/electionCtrl.js +++ b/client/controllers/electionCtrl.js @@ -40,9 +40,13 @@ module.exports = [ ); }; - $scope.updatePriority = function (oldIndex, newIndex) { + $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(); }; /** @@ -57,8 +61,8 @@ module.exports = [ * Removes the given alternative to $scope.priorities * @param {Object} alternative */ - $scope.deselectAlternative = function (alternative) { - $scope.priorities = $scope.priorities.filter((a) => a !== alternative); + $scope.deselectAlternative = function (id) { + $scope.priorities = $scope.priorities.filter((a) => a._id !== id); }; /** diff --git a/client/directives/confirmVoteDirective.js b/client/directives/confirmVoteDirective.js index 2bdfc941..186cc427 100644 --- a/client/directives/confirmVoteDirective.js +++ b/client/directives/confirmVoteDirective.js @@ -3,21 +3,21 @@ module.exports = function () { restrict: 'E', replace: true, scope: { - selectedAlternative: '=', + selectedAlternatives: '=', voteHandler: '&', }, template: '' + '', link: function (scope, elem, attrs) { var clicked = false; - scope.$watch('selectedAlternative', function (newValue) { - if (newValue) { + scope.$watch('selectedAlternatives', function (newValue) { + if (newValue.length > 0) { scope.buttonText = 'Avgi stemme'; clicked = false; } diff --git a/client/directives/sortableDirective.js b/client/directives/sortableDirective.js index 5b71bdad..d8ec7b5b 100644 --- a/client/directives/sortableDirective.js +++ b/client/directives/sortableDirective.js @@ -8,6 +8,7 @@ module.exports = function () { sortableAnimation: '=', sortableOnUpdate: '=', sortableDelay: '=', + sortableHandle: '@sortableHandle', }, link: function (scope, elem) { let img; @@ -15,6 +16,7 @@ module.exports = function () { delay: scope.sortableDelay, delayOnTouchOnly: true, animation: scope.sortableAnimation, + handle: scope.sortableHandle, setData: function (dataTransfer, el) { img = el.cloneNode(true); img.style.visibility = 'hidden'; @@ -27,9 +29,18 @@ module.exports = function () { dataTransfer.setDragImage(img, 0, 0); }, onEnd: function () { - img.parentNode.removeChild(img); + 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/styles/election.styl b/client/styles/election.styl index 9ed6ac06..5ffda263 100644 --- a/client/styles/election.styl +++ b/client/styles/election.styl @@ -5,7 +5,7 @@ text-transform uppercase .helptext - line-height 60px + height 70px .alternatives margin-bottom 30px @@ -31,44 +31,50 @@ ul li margin-bottom 10px - line-height 60px + height 60px border-radius 2px border 1px solid transparent background-color alpha($alternative-background, 0.15) &: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 - p - color $abakus-light - opacity 1 + .content + line-height 60px + text-align center + display inline-block + + p + color $abakus-light + opacity 1 - &.selected - background-color alpha($alternative-background, 0.4) + &.selected + background-color alpha($alternative-background, 0.4) + + p + color white p - color white - - p - color darken($font-gray, 20%) - text-transform uppercase - margin-bottom 0 - font-weight 200 - transition color 0.3s - vertical-align middle + color darken($font-gray, 20%) + text-transform uppercase + font-weight 200 + transition color 0.3s + vertical-align middle + display inline-block + line-height 30px + .numbered counter-reset item - .sortable-drag - display none - li - display inline-block + width 90% counter-increment item position relative - width 90% + display inline-block &:before position absolute @@ -81,7 +87,43 @@ display inline-block text-align center + .content + width 85% + display inline-block + + p + user-select: none; + -webkit-touch-callout: none; + + .drag + position absolute + height 100% + line-height 60px + left 10px + height 100% + vertical-align middle + + .close + display flex + align-items center + justify-content center + height 100% + width 15% + color $abakus-dark + background-color alpha($abakus-dark, 0.4) + @media (max-width 1000px) .alternatives button.btn min-width 60% + + ul + .sortable-drag + display none + + li + .close + opacity .5 + + &:hover + background-color inherit From ee7d352607513c335927408fd072ca4c12c8b1ab Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Mon, 14 Dec 2020 15:26:47 +0100 Subject: [PATCH 43/98] Add blank alternative, some styling and confirm page --- app/views/partials/election.pug | 89 +++++++++------ client/controllers/electionCtrl.js | 9 ++ client/directives/confirmVoteDirective.js | 36 ------ client/directives/index.js | 1 - client/styles/election.styl | 133 +++++++++++++--------- client/styles/main.styl | 6 + 6 files changed, 152 insertions(+), 122 deletions(-) delete mode 100644 client/directives/confirmVoteDirective.js diff --git a/app/views/partials/election.pug b/app/views/partials/election.pug index 01915084..e615157f 100644 --- a/app/views/partials/election.pug +++ b/app/views/partials/election.pug @@ -1,40 +1,61 @@ .center.text-center(ng-switch='!!activeElection') - div(ng-switch-when='true') - .election-info - h2 {{ activeElection.title }} - p {{ activeElection.description }} - - .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 - i.fa.fa-bars.drag - p {{ alternative.description }} - .close(ng-click='deselectAlternative(alternative._id)') - i.fa.fa-close - - .alternatives - h3 Alternativer - ul.list-unstyled - li( - ng-repeat='alternative in getPossibleAlternatives()', - ng-click='selectAlternative(alternative)' + 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' ) - .content - p {{ alternative.description }} + 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 + p + em 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 - confirm-vote(vote-handler='vote()', selected-alternatives='priorities') + button.btn.btn-lg.btn-success(type='button', ng-click='vote()') Bekreft div(ng-switch-default) h2. diff --git a/client/controllers/electionCtrl.js b/client/controllers/electionCtrl.js index f7729c1f..d1a866f1 100644 --- a/client/controllers/electionCtrl.js +++ b/client/controllers/electionCtrl.js @@ -17,6 +17,7 @@ module.exports = [ ) { $scope.activeElection = null; $scope.priorities = []; + $scope.confirmVote = false; /** * Tries to find an active election @@ -65,6 +66,14 @@ module.exports = [ $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 */ diff --git a/client/directives/confirmVoteDirective.js b/client/directives/confirmVoteDirective.js deleted file mode 100644 index 186cc427..00000000 --- a/client/directives/confirmVoteDirective.js +++ /dev/null @@ -1,36 +0,0 @@ -module.exports = function () { - return { - restrict: 'E', - replace: true, - scope: { - selectedAlternatives: '=', - voteHandler: '&', - }, - template: - '' + - '', - - link: function (scope, elem, attrs) { - var clicked = false; - - scope.$watch('selectedAlternatives', function (newValue) { - if (newValue.length > 0) { - 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 516d84aa..da77709d 100644 --- a/client/directives/index.js +++ b/client/directives/index.js @@ -1,6 +1,5 @@ angular .module('voteApp') .directive('deactivateUsers', require('./confirmDeactivateDirective')) - .directive('confirmVote', require('./confirmVoteDirective')) .directive('matchPassword', require('./passwordDirective')) .directive('sortable', require('./sortableDirective')); diff --git a/client/styles/election.styl b/client/styles/election.styl index 5ffda263..a082b229 100644 --- a/client/styles/election.styl +++ b/client/styles/election.styl @@ -1,16 +1,13 @@ -.election-info - margin-bottom 40px - - h2 +.election-info h2 text-transform uppercase .helptext height 70px .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 @@ -29,12 +26,22 @@ background-color #f9f9f9 color $abakus-dark + ul + min-height 60px + text-align right + + .numbered + min-height initial + ul li margin-bottom 10px height 60px border-radius 2px border 1px solid transparent background-color alpha($alternative-background, 0.15) + display flex + justify-content space-between + margin-left auto &:hover &.sortable-chosen @@ -46,11 +53,7 @@ .content line-height 60px text-align center - display inline-block - - p - color $abakus-light - opacity 1 + flex-grow 1 &.selected background-color alpha($alternative-background, 0.4) @@ -59,58 +62,86 @@ 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 30px + line-height 24px - .numbered - counter-reset item + .icon + display flex + align-items center + justify-content center + height 100% + width 15% - li - width 90% - counter-increment item - position relative + &.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 - &: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 p + user-select: none; + -webkit-touch-callout: none; + width 90% - .content - width 85% - display inline-block - p - user-select: none; - -webkit-touch-callout: none; - - .drag - position absolute - height 100% - line-height 60px - left 10px - height 100% - vertical-align middle - - .close - display flex - align-items center - justify-content center - height 100% - width 15% - color $abakus-dark - background-color alpha($abakus-dark, 0.4) + .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 + vertical-align middle + margin 0 + @media (max-width 1000px) .alternatives diff --git a/client/styles/main.styl b/client/styles/main.styl index 7a888d59..9a918fc9 100644 --- a/client/styles/main.styl +++ b/client/styles/main.styl @@ -142,6 +142,12 @@ label input width 100% + .header + padding-top 0 + + footer + padding-top 20px + .usage-flex display flex justify-content space-between From 748e3d710997b0a16407b8da1dad9fb390fd8640 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Tue, 15 Dec 2020 15:10:59 +0100 Subject: [PATCH 44/98] Remove border to match buttons & small style tweaks --- client/styles/election.styl | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/styles/election.styl b/client/styles/election.styl index a082b229..ab15a46b 100644 --- a/client/styles/election.styl +++ b/client/styles/election.styl @@ -37,7 +37,6 @@ margin-bottom 10px height 60px border-radius 2px - border 1px solid transparent background-color alpha($alternative-background, 0.15) display flex justify-content space-between @@ -46,7 +45,6 @@ &: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 @@ -77,9 +75,13 @@ display flex align-items center justify-content center + border-radius 2px height 100% width 15% + &: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) @@ -109,12 +111,14 @@ 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% From 4844789833aa0dd5c023d00b0cadc91fa4f208db Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Thu, 17 Dec 2020 16:22:56 +0100 Subject: [PATCH 45/98] Replace p em with single i tag & update function docstrings --- app/views/partials/election.pug | 3 +-- client/controllers/electionCtrl.js | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/views/partials/election.pug b/app/views/partials/election.pug index e615157f..82134dc8 100644 --- a/app/views/partials/election.pug +++ b/app/views/partials/election.pug @@ -46,8 +46,7 @@ .ballot div(ng-switch-when='true') h3 Blank stemme - p - em Din stemme vil fortsatt påvirke hvor mange stemmer som kreves for å vinne + 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') diff --git a/client/controllers/electionCtrl.js b/client/controllers/electionCtrl.js index d1a866f1..f998f549 100644 --- a/client/controllers/electionCtrl.js +++ b/client/controllers/electionCtrl.js @@ -41,6 +41,10 @@ module.exports = [ ); }; + /** + * 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]; @@ -60,7 +64,7 @@ module.exports = [ /** * Removes the given alternative to $scope.priorities - * @param {Object} alternative + * @param {string} id */ $scope.deselectAlternative = function (id) { $scope.priorities = $scope.priorities.filter((a) => a._id !== id); From 2c6daa3a91453de63d8c6400ac10f32c203fb245 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 14 Dec 2020 18:14:18 +0100 Subject: [PATCH 46/98] Add OpaVote testcase and dataset --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 99d69a76..62d2bb4c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,11 @@ "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", +<<<<<<< HEAD "mocha": "NODE_ENV=test MONGO_URL=${MONGO_URL:-'mongodb://localhost:27017/vote-test'} nyc mocha test/**/*.test.js --exit --timeout 30000", +======= + "mocha": "NODE_ENV=test MONGO_URL=${MONGO_URL:-'mongodb://localhost:27017/vote-test'} nyc mocha test/**/*.test.js --exit --timeout 10000", +>>>>>>> 79fdfb1... Add OpaVote testcase and dataset "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" From 7c8293036c81e30604cf8b3333e52fe6b83bd24c Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 15 Dec 2020 19:59:56 +0100 Subject: [PATCH 47/98] OpaVote needs 30000ms to run on CI --- package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/package.json b/package.json index 62d2bb4c..99d69a76 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,7 @@ "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", -<<<<<<< HEAD "mocha": "NODE_ENV=test MONGO_URL=${MONGO_URL:-'mongodb://localhost:27017/vote-test'} nyc mocha test/**/*.test.js --exit --timeout 30000", -======= - "mocha": "NODE_ENV=test MONGO_URL=${MONGO_URL:-'mongodb://localhost:27017/vote-test'} nyc mocha test/**/*.test.js --exit --timeout 10000", ->>>>>>> 79fdfb1... Add OpaVote testcase and dataset "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" From 0f68004117f86e81a2da63a4a8d8e919949530e7 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 14 Dec 2020 13:21:30 +0100 Subject: [PATCH 48/98] Redo retriveVotePage to show priorities --- app/views/partials/retrieveVote.pug | 10 ++++++---- client/styles/main.styl | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/views/partials/retrieveVote.pug b/app/views/partials/retrieveVote.pug index 170ea51e..b48167af 100644 --- a/app/views/partials/retrieveVote.pug +++ b/app/views/partials/retrieveVote.pug @@ -20,7 +20,9 @@ ) 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 }} + h4 Din prioritering på: {{ vote.election.title }} + table.table.mono + tbody + tr(ng-repeat='priority in vote.priorities') + th.th-right Prioritering {{ $index + 1 }}: + th.th-left {{ priority.description }} diff --git a/client/styles/main.styl b/client/styles/main.styl index 9a918fc9..7105638d 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 @@ -156,6 +161,14 @@ label a align-self flex-end +.th-right + text-align right + width 50% + +.th-left + text-align left + width 50% + @import 'election' @import 'admin' From e63611ac76b36705ea62f1200055963c384af7f2 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 14 Dec 2020 13:48:05 +0100 Subject: [PATCH 49/98] New view and logic for administration of election --- app/views/partials/admin/editElection.pug | 58 +++++++++++++++++++---- client/controllers/editElectionCtrl.js | 41 ++++++++-------- client/services/adminElectionService.js | 2 +- client/styles/admin.styl | 15 ++++++ 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/app/views/partials/admin/editElection.pug b/app/views/partials/admin/editElection.pug index 39ae1695..2f93509f 100644 --- a/app/views/partials/admin/editElection.pug +++ b/app/views/partials/admin/editElection.pug @@ -22,8 +22,6 @@ ul.list-unstyled li(ng-repeat='alternative in election.alternatives') p {{ alternative.description }} - span(ng-if='showCount') - | {{ alternative.votes }} - {{ getPercentage(alternative.votes) }} % form.add-alternative.form-group( name='alternativeForm', @@ -44,16 +42,58 @@ 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 = {{ election.voteCount }} + tr + th.th-left Plasser + th.th-right = {{ election.seats }} + tr + th.th-left Terskel + th.th-right ⌊{{ election.voteCount }}/{{ election.seats + 1 }}⌋ + 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 + tbody + tr(ng-repeat='winner in election.result.winners') + th.th-right Vinner {{ $index + 1 }}: + th.th-left {{ winner.description }} diff --git a/client/controllers/editElectionCtrl.js b/client/controllers/editElectionCtrl.js index f40b3e93..70bff383 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(); } }; 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/styles/admin.styl b/client/styles/admin.styl index 5401d420..04d502f2 100644 --- a/client/styles/admin.styl +++ b/client/styles/admin.styl @@ -128,3 +128,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 12px + line-height 20px + h5 + margin 5px + text-decoration underline + font-weight 1000 From c619505b98078b7f556522779046353b9ce2e9bb Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 14 Dec 2020 18:55:30 +0100 Subject: [PATCH 50/98] Eslint allow spread {...} --- .eslintrc | 1 + package.json | 1 + yarn.lock | 506 ++++++++++++++++++++++----------------------------- 3 files changed, 221 insertions(+), 287 deletions(-) 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/package.json b/package.json index 99d69a76..6ad4a922 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ }, "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", diff --git a/yarn.lock b/yarn.lock index ad3ee379..548a621b 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== @@ -519,9 +442,9 @@ 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" @@ -804,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" @@ -848,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== @@ -1202,6 +1115,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" @@ -1248,9 +1173,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" @@ -1316,7 +1241,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== @@ -1356,7 +1281,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== @@ -1450,11 +1375,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: @@ -1480,9 +1405,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" @@ -1557,9 +1482,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" @@ -1742,9 +1667,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" @@ -1752,7 +1677,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" @@ -1813,11 +1738,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" @@ -1952,11 +1878,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" @@ -1990,9 +1911,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" @@ -2009,7 +1930,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== @@ -2124,9 +2045,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" @@ -2333,12 +2254,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" @@ -2354,13 +2275,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" @@ -2730,23 +2665,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" @@ -3292,9 +3210,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" @@ -3635,9 +3553,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" @@ -3912,9 +3830,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" @@ -3937,9 +3855,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" @@ -4001,9 +3919,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" @@ -4112,9 +4030,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" @@ -4225,9 +4143,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" @@ -4503,9 +4421,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" @@ -4628,9 +4546,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" @@ -4843,6 +4761,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" @@ -5180,11 +5105,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" @@ -5252,11 +5177,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" @@ -5279,9 +5209,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" @@ -5518,9 +5448,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" @@ -5555,12 +5485,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" @@ -5637,9 +5568,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" @@ -5978,13 +5909,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" @@ -5992,9 +5924,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" @@ -6441,10 +6373,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" @@ -6645,14 +6577,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== @@ -6847,9 +6772,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" @@ -7103,11 +7030,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" @@ -7222,9 +7149,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" @@ -7361,20 +7288,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" @@ -7643,9 +7570,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" @@ -7729,9 +7656,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" @@ -7945,7 +7872,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= @@ -8039,23 +7966,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" @@ -8341,15 +8268,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" From fcfaaac161083c834fe3f053659da08f4386743c Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Fri, 15 Jan 2021 16:30:53 +0100 Subject: [PATCH 51/98] Initial implementation of user gen using mail --- .gitignore | 1 + app/controllers/user.js | 35 +++ app/digital/mail.js | 39 +++ app/digital/template.html | 281 ++++++++++++++++++ app/routes/api/user.js | 2 + app/views/moderatorIndex.pug | 2 + app/views/partials/moderator/generateUser.pug | 25 ++ client/appRoutes.js | 5 + client/controllers/generateUserCtrl.js | 33 ++ client/controllers/index.js | 1 + client/services/userService.js | 4 + package.json | 2 + yarn.lock | 10 + 13 files changed, 440 insertions(+) create mode 100644 app/digital/mail.js create mode 100644 app/digital/template.html create mode 100644 app/views/partials/moderator/generateUser.pug create mode 100644 client/controllers/generateUserCtrl.js 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/app/controllers/user.js b/app/controllers/user.js index 36fe596e..6f7f49fe 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -3,6 +3,10 @@ const User = require('../models/user'); const errors = require('../errors'); const errorChecks = require('../errors/error-checks'); +const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto'); +const { mailHandler } = require('../digital/mail'); + exports.count = async (req, res) => { const query = { admin: false, moderator: false }; if (req.query.active === 'true') { @@ -39,6 +43,37 @@ exports.create = (req, res) => { }); }; +exports.generate = (req, res) => { + const randomPassword = crypto.randomBytes(5).toString('hex'); + const cardKey = uuidv4(); + const userObject = { + username: req.body.username, + cardKey: cardKey, + }; + + const user = new User(userObject); + return User.register(user, randomPassword) + .then(async (createdUser) => { + mailHandler(createdUser, randomPassword).then(() => + res.status(201).json(createdUser.getCleanUser()) + ); + }) + .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/digital/mail.js b/app/digital/mail.js new file mode 100644 index 00000000..02d377b5 --- /dev/null +++ b/app/digital/mail.js @@ -0,0 +1,39 @@ +const nodemailer = require('nodemailer'); +const fs = require('fs'); +const path = require('path'); +const creds = require('./client_secret.json'); + +const transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 465, + secure: true, + auth: { + type: 'OAuth2', + user: creds.abakus_from_mail, + serviceClient: creds.client_id, + privateKey: creds.private_key, + }, +}); + +const transporterTest = nodemailer.createTransport({ + host: 'smtp.ethereal.email', + port: 587, + auth: { + user: 'aileen.trantow@ethereal.email', + pass: 'Ejw5B3KC9HSnbKcz5d', + }, +}); + +exports.mailHandler = async (user, pass) => { + const html = fs + .readFileSync(path.resolve(__dirname, './template.html'), 'utf8') + .replace('{{USERNAME}}', user.username) + .replace('{{PASSWORD}}', pass); + return transporter.sendMail({ + from: `"Abakus <${creds.abakus_from_mail}>`, + to: `${user.username} <${user.username}@stud.ntnu.no}>`, + subject: `VOTE Login Credentials: ${user.username}`, + text: `Username: ${user.username}, Password: ${pass}`, + html: html, + }); +}; diff --git a/app/digital/template.html b/app/digital/template.html new file mode 100644 index 00000000..eccc10fb --- /dev/null +++ b/app/digital/template.html @@ -0,0 +1,281 @@ + + + + + + {{ title }} | {{ site }} + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+ +
+ + + + + +
+ + + +
+
+ +
+
+
+
+ + + + +

Velkommen til Digital Genfors 2021!

+
+ VOTE +
+

Hei! Vi ser du har meldt deg på Genfors 2021 på abakus.no. I år blir denne digital og vi har derfor laget en bruker i VOTE for deg. Hvis du ikke har vært på Genfors før og er usikker på hva dette betyr går det fint, da det vil bli forklart på Genfors.

+
+

Brukernavn: {{USERNAME}}

+

Passord: {{PASSWORD}}

+

Ditt passord har blitt generert av VOTE, og vi i Webkom vet ikke hva det er.

+ Logg inn her +
+
+
+ + + + +
+ Abakus Linjeforening
+ webkom@abakus.no
+ abakus.no

+
+
+
+ + 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/views/moderatorIndex.pug b/app/views/moderatorIndex.pug index bbaf0eea..573b29c1 100644 --- a/app/views/moderatorIndex.pug +++ b/app/views/moderatorIndex.pug @@ -9,6 +9,8 @@ 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 diff --git a/app/views/partials/moderator/generateUser.pug b/app/views/partials/moderator/generateUser.pug new file mode 100644 index 00000000..270ddad2 --- /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(ntnuUsername)', + name='createUserForm' + ) + .form-group + label NTNU brukernavn + input.form-control( + type='text', + name='username', + placeholder='Skriv inn NTNU brukernavn', + ng-model='ntnuUsername', + ng-minlength='5', + ng-pattern='/^[a-zA-Z0-9]+$/' + ) + p.text-danger(ng-show='createUserForm.username.$error.minlength') + | NTNU-Brukernavn må være minst 5 tegn + p.text-danger(ng-show='createUserForm.username.$error.pattern') + | NTNU-Brukernavn kan bare inneholde tall eller bokstaver fra A-Z + + button#submit.btn.btn-default.btn-lg( + type='submit', + ng-disabled='createUserForm.$invalid' + ) Generer bruker diff --git a/client/appRoutes.js b/client/appRoutes.js index a2cbe1c0..733217a3 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', diff --git a/client/controllers/generateUserCtrl.js b/client/controllers/generateUserCtrl.js new file mode 100644 index 00000000..d36a2003 --- /dev/null +++ b/client/controllers/generateUserCtrl.js @@ -0,0 +1,33 @@ +module.exports = [ + '$scope', + 'userService', + 'alertService', + function ($scope, userService, alertService) { + $scope.generateUser = {}; + + $scope.generateUser = function (username) { + userService.generateUser({ username }).then( + function (response) { + alertService.addSuccess('Bruker generert!'); + $scope.username = ''; + }, + function (response) { + switch (response.data.name) { + case 'DuplicateUsernameError': + alertService.addError( + 'Dette ntnu-brukernavnet er allerede registrert.' + ); + break; + case 'DuplicateCardError': + alertService.addError( + 'Dette kortet er allerede blitt registrert.' + ); + break; + default: + alertService.addError(); + } + } + ); + }; + }, +]; diff --git a/client/controllers/index.js b/client/controllers/index.js index 9ef1791a..bcd1e7ab 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')) diff --git a/client/services/userService.js b/client/services/userService.js index c1bb75d9..5e341ae6 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 (username) { + return $http.post('/api/user/generate', username); + }; + this.changeCard = function (user) { return $http.put('/api/user/' + user.username + '/change_card', user); }; diff --git a/package.json b/package.json index 6ad4a922..91b7922a 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "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", @@ -73,6 +74,7 @@ "style-loader": "0.23.1", "stylus": "0.54.5", "stylus-loader": "3.0.2", + "uuid": "8.3.2", "webpack": "4.28.4", "webpack-cli": "4.0.0", "yaml": "1.3.2" diff --git a/yarn.lock b/yarn.lock index 548a621b..13611170 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5339,6 +5339,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" @@ -7901,6 +7906,11 @@ uuid@3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== +uuid@8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + uuid@^3.3.2, uuid@^3.3.3: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" From 341f9d1b9b45ed42300608b2f02fe2498ac8be42 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 16 Jan 2021 16:23:31 +0100 Subject: [PATCH 52/98] Add accessCode for election. --- app/controllers/election.js | 33 +++++- app/digital/mail.js | 2 +- app/models/election.js | 6 +- app/views/partials/admin/editElection.pug | 1 + app/views/partials/election.pug | 128 +++++++++++++--------- client/controllers/electionCtrl.js | 28 ++++- client/services/electionService.js | 7 +- client/styles/election.styl | 8 ++ docker-compose.yml | 5 +- 9 files changed, 150 insertions(+), 68 deletions(-) diff --git a/app/controllers/election.js b/app/controllers/election.js index b8194213..a6e9fc53 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,12 +18,40 @@ exports.load = (req, res, next, electionId) => }); exports.retrieveActive = (req, res) => - Election.findOne({ active: true, hasVotedUsers: { $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); + // There is no active election (that the user has not voted on) + if (!election) { + res.status(404).send(); + } + // If the user is active, then we can return the election right + // away, since they have allready passed the access code prompt + if (req.user.active) { + res.status(200).json(election); + } + // There is an active election that the user has not voted on + // but they did not pass any (or the correct) access code, + // so we return 403 which prompts a access code input field. + else if ( + !req.query.accessCode || + election.accessCode !== Number(req.query.accessCode) + ) { + res.status(403).send(); + } + // There is an active election that the user and the user has + // the correct access code. Therefore we activate the users + // account (allowing them to vote), and return the elction. + else { + User.findByIdAndUpdate({ _id: req.user._id }, { active: true }).then( + res.status(200).json(election) + ); + } }); exports.create = (req, res) => diff --git a/app/digital/mail.js b/app/digital/mail.js index 02d377b5..5e684a25 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -29,7 +29,7 @@ exports.mailHandler = async (user, pass) => { .readFileSync(path.resolve(__dirname, './template.html'), 'utf8') .replace('{{USERNAME}}', user.username) .replace('{{PASSWORD}}', pass); - return transporter.sendMail({ + return transporterTest.sendMail({ from: `"Abakus <${creds.abakus_from_mail}>`, to: `${user.username} <${user.username}@stud.ntnu.no}>`, subject: `VOTE Login Credentials: ${user.username}`, diff --git a/app/models/election.js b/app/models/election.js index 73f26795..170a42a4 100644 --- a/app/models/election.js +++ b/app/models/election.js @@ -53,6 +53,10 @@ const electionSchema = new Schema({ message: 'Strict elections must have exactly one seat', }, }, + accessCode: { + type: Number, + default: Math.floor(Math.random() * (10000 - 1000) + 1000), + }, }); electionSchema.pre('remove', function (next) { @@ -108,7 +112,7 @@ electionSchema.methods.addAlternative = async function (alternative) { return savedAlternative; }; -electionSchema.methods.addVote = async function (user, priorities) { +electionSchema.methods.addVote = async function (user, priorities, accessCode) { 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(); diff --git a/app/views/partials/admin/editElection.pug b/app/views/partials/admin/editElection.pug index 2f93509f..1a6ce597 100644 --- a/app/views/partials/admin/editElection.pug +++ b/app/views/partials/admin/editElection.pug @@ -3,6 +3,7 @@ .election-info.admin h2 {{ election.title }} p {{ election.description }} + h3 Tilgangskode: {{ election.accessCode }} .election-info.admin h3.user-status diff --git a/app/views/partials/election.pug b/app/views/partials/election.pug index 82134dc8..1215d62a 100644 --- a/app/views/partials/election.pug +++ b/app/views/partials/election.pug @@ -1,60 +1,80 @@ -.center.text-center(ng-switch='!!activeElection') - 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') +.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-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='text', + name='accessCode', + ng-model='accessCode', + maxLength='4', + placeholder='----' + ) - button.btn.btn-lg.btn-success(type='button', ng-click='vote()') Bekreft + button#submit.btn.btn-default.btn-lg.btn-success( + type='submit', + ng-disabled='accessCode.length != 4' + ) Verifiser div(ng-switch-default) h2. diff --git a/client/controllers/electionCtrl.js b/client/controllers/electionCtrl.js index f998f549..d39bdd6c 100644 --- a/client/controllers/electionCtrl.js +++ b/client/controllers/electionCtrl.js @@ -16,23 +16,41 @@ module.exports = [ localStorageService ) { $scope.activeElection = 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.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 () { @@ -86,6 +104,10 @@ module.exports = [ function (response) { $window.scrollTo(0, 0); $scope.activeElection = null; + $scope.electionExists = false; + $scope.confirmVote = false; + $scope.correctCode = false; + $scope.accessCode = ''; alertService.addSuccess('Takk for din stemme!'); localStorageService.set('voteHash', response.data.hash); getActiveElection(); 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/styles/election.styl b/client/styles/election.styl index ab15a46b..6ca378d4 100644 --- a/client/styles/election.styl +++ b/client/styles/election.styl @@ -4,6 +4,14 @@ .helptext height 70px +.access-code + input + width 100px + text-align center + font-size 30px + margin auto + font-family monospace + .alternatives margin-bottom 20px padding-top 20px 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' - From a42b924bc3248c27109370a96f2b6d09dffcb93d Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 16 Jan 2021 17:51:49 +0100 Subject: [PATCH 53/98] Return the res objects to avoid double ERR_HTTP_HEADERS_SENT --- app/controllers/election.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/controllers/election.js b/app/controllers/election.js index a6e9fc53..ad78b5e9 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -28,12 +28,12 @@ exports.retrieveActive = (req, res) => .then((election) => { // There is no active election (that the user has not voted on) if (!election) { - res.status(404).send(); + return res.sendStatus(404); } // If the user is active, then we can return the election right // away, since they have allready passed the access code prompt if (req.user.active) { - res.status(200).json(election); + return res.status(200).json(election); } // There is an active election that the user has not voted on // but they did not pass any (or the correct) access code, @@ -42,15 +42,16 @@ exports.retrieveActive = (req, res) => !req.query.accessCode || election.accessCode !== Number(req.query.accessCode) ) { - res.status(403).send(); + return res.sendStatus(403); } // There is an active election that the user and the user has // the correct access code. Therefore we activate the users // account (allowing them to vote), and return the elction. else { - User.findByIdAndUpdate({ _id: req.user._id }, { active: true }).then( - res.status(200).json(election) - ); + return User.findByIdAndUpdate( + { _id: req.user._id }, + { active: true } + ).then(res.status(200).json(election)); } }); From 3987b5e3c0c9a98e5225a971a3aa0fc8f212938a Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 16 Jan 2021 18:30:46 +0100 Subject: [PATCH 54/98] Add tests for accessCode --- test/api/election.test.js | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/test/api/election.test.js b/test/api/election.test.js index 2a5f8bad..5f402b88 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 = { @@ -489,7 +490,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') @@ -502,16 +503,41 @@ 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(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 () { From e800c5b9310c3bdffebc7879379f485fa39d9b23 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 16 Jan 2021 20:38:36 +0100 Subject: [PATCH 55/98] Switch from username to email for user gen --- app/controllers/user.js | 14 +++++++---- app/digital/mail.js | 23 ++++++------------ app/views/partials/moderator/generateUser.pug | 24 +++++-------------- client/controllers/generateUserCtrl.js | 6 ++--- client/services/userService.js | 4 ++-- 5 files changed, 27 insertions(+), 44 deletions(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index 6f7f49fe..40dcf926 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -47,16 +47,20 @@ exports.generate = (req, res) => { const randomPassword = crypto.randomBytes(5).toString('hex'); const cardKey = uuidv4(); const userObject = { - username: req.body.username, + username: req.body.email + .split('@')[0] + .match(/[a-zA-Z]+/g) + .join(''), cardKey: cardKey, }; - const user = new User(userObject); return User.register(user, randomPassword) .then(async (createdUser) => { - mailHandler(createdUser, randomPassword).then(() => - res.status(201).json(createdUser.getCleanUser()) - ); + mailHandler( + createdUser.username, + req.body.email, + randomPassword + ).then(() => res.status(201).json(createdUser.getCleanUser())); }) .catch(mongoose.Error.ValidationError, (err) => { throw new errors.ValidationError(err.errors); diff --git a/app/digital/mail.js b/app/digital/mail.js index 5e684a25..b8921a15 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -15,25 +15,16 @@ const transporter = nodemailer.createTransport({ }, }); -const transporterTest = nodemailer.createTransport({ - host: 'smtp.ethereal.email', - port: 587, - auth: { - user: 'aileen.trantow@ethereal.email', - pass: 'Ejw5B3KC9HSnbKcz5d', - }, -}); - -exports.mailHandler = async (user, pass) => { +exports.mailHandler = async (username, email, pass) => { const html = fs .readFileSync(path.resolve(__dirname, './template.html'), 'utf8') - .replace('{{USERNAME}}', user.username) + .replace('{{USERNAME}}', username) .replace('{{PASSWORD}}', pass); - return transporterTest.sendMail({ - from: `"Abakus <${creds.abakus_from_mail}>`, - to: `${user.username} <${user.username}@stud.ntnu.no}>`, - subject: `VOTE Login Credentials: ${user.username}`, - text: `Username: ${user.username}, Password: ${pass}`, + return transporter.sendMail({ + from: `VOTE - Abakus <${creds.abakus_from_mail}>`, + to: `${username} <${email}>`, + subject: `VOTE Login Credentials: ${username}`, + text: `Username: ${username}, Password: ${pass}`, html: html, }); }; diff --git a/app/views/partials/moderator/generateUser.pug b/app/views/partials/moderator/generateUser.pug index 270ddad2..d6cde387 100644 --- a/app/views/partials/moderator/generateUser.pug +++ b/app/views/partials/moderator/generateUser.pug @@ -1,25 +1,13 @@ .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(ntnuUsername)', - name='createUserForm' - ) + form.form-group(ng-submit='generateUser(email)', name='generateUserForm') .form-group - label NTNU brukernavn + label Epost input.form-control( type='text', - name='username', - placeholder='Skriv inn NTNU brukernavn', - ng-model='ntnuUsername', - ng-minlength='5', - ng-pattern='/^[a-zA-Z0-9]+$/' + name='email', + placeholder='Skriv inn epost', + ng-model='email' ) - p.text-danger(ng-show='createUserForm.username.$error.minlength') - | NTNU-Brukernavn må være minst 5 tegn - p.text-danger(ng-show='createUserForm.username.$error.pattern') - | NTNU-Brukernavn kan bare inneholde tall eller bokstaver fra A-Z - button#submit.btn.btn-default.btn-lg( - type='submit', - ng-disabled='createUserForm.$invalid' - ) Generer bruker + button#submit.btn.btn-default.btn-lg(type='submit') Generer bruker diff --git a/client/controllers/generateUserCtrl.js b/client/controllers/generateUserCtrl.js index d36a2003..b950465b 100644 --- a/client/controllers/generateUserCtrl.js +++ b/client/controllers/generateUserCtrl.js @@ -5,11 +5,11 @@ module.exports = [ function ($scope, userService, alertService) { $scope.generateUser = {}; - $scope.generateUser = function (username) { - userService.generateUser({ username }).then( + $scope.generateUser = function (email) { + userService.generateUser({ email }).then( function (response) { alertService.addSuccess('Bruker generert!'); - $scope.username = ''; + $scope.email = ''; }, function (response) { switch (response.data.name) { diff --git a/client/services/userService.js b/client/services/userService.js index 5e341ae6..d2e5e1de 100644 --- a/client/services/userService.js +++ b/client/services/userService.js @@ -9,8 +9,8 @@ module.exports = [ return $http.post('/api/user', user); }; - this.generateUser = function (username) { - return $http.post('/api/user/generate', username); + this.generateUser = function (email) { + return $http.post('/api/user/generate', email); }; this.changeCard = function (user) { From 5c751f04c61d1082f1ed23affd29d08b7e04fe9e Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 16 Jan 2021 20:38:57 +0100 Subject: [PATCH 56/98] Clean template in order to avoid spam-filter --- app/digital/template.html | 70 +++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/app/digital/template.html b/app/digital/template.html index eccc10fb..50660da4 100644 --- a/app/digital/template.html +++ b/app/digital/template.html @@ -206,42 +206,42 @@ - - - + + @@ -269,7 +272,6 @@

Velkommen til Digital Genfors 2021!

-
- - - - + +
- -
- - - - - -
- - - -
-
+ + + - - + + + + +
+
+
+ + + + - -
-
-
-
+ + + + +
+ + + +
+ + +
+
+
From dd2a74b714a3f3653c08a82d62b2ee75f75faaa4 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 16 Jan 2021 20:39:23 +0100 Subject: [PATCH 57/98] Fix styling for entering code --- app/views/partials/election.pug | 5 ++--- client/styles/election.styl | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/views/partials/election.pug b/app/views/partials/election.pug index 1215d62a..4dac7f05 100644 --- a/app/views/partials/election.pug +++ b/app/views/partials/election.pug @@ -64,16 +64,15 @@ .form-group.access-code(required) label Kode input.form-control( - type='text', + type='number', name='accessCode', ng-model='accessCode', - maxLength='4', placeholder='----' ) button#submit.btn.btn-default.btn-lg.btn-success( type='submit', - ng-disabled='accessCode.length != 4' + ng-disabled='accessCode.toString().length != 4' ) Verifiser div(ng-switch-default) diff --git a/client/styles/election.styl b/client/styles/election.styl index 6ca378d4..912f7ba2 100644 --- a/client/styles/election.styl +++ b/client/styles/election.styl @@ -6,9 +6,9 @@ .access-code input - width 100px + width 150px text-align center - font-size 30px + font-size 25px margin auto font-family monospace From ed0669506c42cccd5fc14e42b6ef65103f268d1c Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 16 Jan 2021 21:13:57 +0100 Subject: [PATCH 58/98] Don't change docker-compose in this branch --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4d6fb195..72beaa12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,10 +2,10 @@ version: '2' services: mongo: - image: mongo:4.4 + image: mongo:3.6 ports: - '127.0.0.1:27017:27017' redis: - image: redis:6.0 + image: redis:latest ports: - '127.0.0.1:6379:6379' From eb86fb42996dee90a9d835b15b253471a7954631 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 17 Jan 2021 23:32:05 +0100 Subject: [PATCH 59/98] Use random username and create new field for unActivatedEmail that we remove on first auth --- app/controllers/user.js | 31 +++++++++++++++---------------- app/digital/mail.js | 16 ++++++++++------ app/digital/template.html | 12 +++++++----- app/models/user.js | 6 ++++++ app/routes/auth.js | 10 +++++++++- package.json | 2 +- yarn.lock | 23 ++++++++++++++++++----- 7 files changed, 66 insertions(+), 34 deletions(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index 40dcf926..4ef8d355 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -3,9 +3,9 @@ const User = require('../models/user'); const errors = require('../errors'); const errorChecks = require('../errors/error-checks'); -const { v4: uuidv4 } = require('uuid'); 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 }; @@ -44,24 +44,23 @@ exports.create = (req, res) => { }; exports.generate = (req, res) => { - const randomPassword = crypto.randomBytes(5).toString('hex'); - const cardKey = uuidv4(); + const username = short.generate(); + const cardKey = short.generate(); + const password = crypto.randomBytes(10).toString('hex'); + const unActivatedEmail = req.body.email; const userObject = { - username: req.body.email - .split('@')[0] - .match(/[a-zA-Z]+/g) - .join(''), - cardKey: cardKey, + username, + password, + cardKey, + unActivatedEmail, }; const user = new User(userObject); - return User.register(user, randomPassword) - .then(async (createdUser) => { - mailHandler( - createdUser.username, - req.body.email, - randomPassword - ).then(() => res.status(201).json(createdUser.getCleanUser())); - }) + return User.register(user, password) + .then((createdUser) => + mailHandler(userObject) + .then(() => createdUser.getCleanUser()) + .then((user) => res.status(201).json(user)) + ) .catch(mongoose.Error.ValidationError, (err) => { throw new errors.ValidationError(err.errors); }) diff --git a/app/digital/mail.js b/app/digital/mail.js index b8921a15..b9047437 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -15,16 +15,20 @@ const transporter = nodemailer.createTransport({ }, }); -exports.mailHandler = async (username, email, pass) => { +exports.mailHandler = async (user) => { + const { username, password, unActivatedEmail } = user; + // Safe username and password + const cleanUsername = username.replace(/\W/g, ''); + const cleanPassword = password.replace(/\W/g, ''); const html = fs .readFileSync(path.resolve(__dirname, './template.html'), 'utf8') - .replace('{{USERNAME}}', username) - .replace('{{PASSWORD}}', pass); + .replace('{{USERNAME}}', cleanUsername) + .replace('{{PASSWORD}}', cleanPassword); return transporter.sendMail({ from: `VOTE - Abakus <${creds.abakus_from_mail}>`, - to: `${username} <${email}>`, - subject: `VOTE Login Credentials: ${username}`, - text: `Username: ${username}, Password: ${pass}`, + to: `${cleanUsername} <${unActivatedEmail}>`, + subject: `VOTE Login Credentials`, + text: `Username: ${cleanUsername}, Password: ${cleanPassword}`, html: html, }); }; diff --git a/app/digital/template.html b/app/digital/template.html index 50660da4..3ac9dcc2 100644 --- a/app/digital/template.html +++ b/app/digital/template.html @@ -252,11 +252,14 @@

Velkommen til Digital Genfors 2021!

-

Hei! Vi ser du har meldt deg på Genfors 2021 på abakus.no. I år blir denne digital og vi har derfor laget en bruker i VOTE for deg. Hvis du ikke har vært på Genfors før og er usikker på hva dette betyr går det fint, da det vil bli forklart på Genfors.

+

Hei! Vi ser du har meldt deg på Genfors 2021 på abakus.no.

+

I år blir denne digital og vi har derfor laget en bruker i VOTE for deg. +

Brukernavn: {{USERNAME}}

+

Passord: {{PASSWORD}}


-

Brukernavn: {{USERNAME}}

-

Passord: {{PASSWORD}}

-

Ditt passord har blitt generert av VOTE, og vi i Webkom vet ikke hva det er.

+

Hvis du ikke har vært på Genfors før og er usikker på hva dette betyr går det fint, da det vil bli forklart på Genfors.

+

Ditt brukernavn og passord har blitt generert av VOTE, og vi i Webkom vet ikke hva det er.

+

Det er derfor viktig at du passer godt på de og ikke deler de med noen.

Logg inn her
Abakus Linjeforening
webkom@abakus.no
- abakus.no

diff --git a/app/models/user.js b/app/models/user.js index 324069e4..b11ca365 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -35,6 +35,12 @@ const userSchema = new Schema({ required: true, unique: true, }, + // unActivatedEmail is used to by the digital implementation to ensure we can track + // down users that didn't get their initial user, so it an be deleted before recreating. + unActivatedEmail: { + type: String, + required: true, + }, }); userSchema.pre('save', function (next) { diff --git a/app/routes/auth.js b/app/routes/auth.js index b246c6e3..aee14d42 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 User = require('../models/user'); router.get('/login', (req, res) => { const csrfToken = process.env.NODE_ENV !== 'test' ? req.csrfToken() : 'test'; @@ -22,7 +23,14 @@ router.post( failureRedirect: '/auth/login', failureFlash: 'Brukernavn og/eller passord er feil.', }), - (req, res) => { + async (req, res) => { + const { _id, unActivatedEmail } = req.user; + // If they can login with an unActivatedEmail user we'll set the user's + // unActivatedEmail to blank to show that this user has been able to login + if (unActivatedEmail) { + await User.findByIdAndUpdate({ _id: _id }, { unActivatedEmail: '' }); + } + // If the user tried to access a specific page before, redirect there: // TODO FIXME //const path = req.session.originalPath || '/'; diff --git a/package.json b/package.json index 91b7922a..b8122205 100644 --- a/package.json +++ b/package.json @@ -69,12 +69,12 @@ "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", - "uuid": "8.3.2", "webpack": "4.28.4", "webpack-cli": "4.0.0", "yaml": "1.3.2" diff --git a/yarn.lock b/yarn.lock index 13611170..98fe3bc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -886,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" @@ -6927,6 +6932,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" @@ -7906,16 +7919,16 @@ uuid@3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== -uuid@8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - uuid@^3.3.2, uuid@^3.3.3: version "3.4.0" 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" From 7ce6d69109885119a21f66119036b92929bd47cf Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 17 Jan 2021 23:33:09 +0100 Subject: [PATCH 60/98] Rewrite and clean-up bad code from comments --- app/controllers/election.js | 33 ++++++++++++-------------- app/models/election.js | 2 +- client/controllers/generateUserCtrl.js | 2 -- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/app/controllers/election.js b/app/controllers/election.js index ad78b5e9..d123cca7 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -25,34 +25,31 @@ exports.retrieveActive = (req, res) => .select('-hasVotedUsers') .populate('alternatives') .exec() - .then((election) => { + .then(async (election) => { + const { user, query } = req; // There is no active election (that the user has not voted on) if (!election) { return res.sendStatus(404); } - // If the user is active, then we can return the election right - // away, since they have allready passed the access code prompt - if (req.user.active) { + + // User is active, return the election + if (user.active) { return res.status(200).json(election); } - // There is an active election that the user has not voted on - // but they did not pass any (or the correct) access code, + + // Active election but wrong or not access code submitted, // so we return 403 which prompts a access code input field. - else if ( - !req.query.accessCode || - election.accessCode !== Number(req.query.accessCode) + if ( + !query.accessCode || + election.accessCode !== Number(query.accessCode) ) { return res.sendStatus(403); } - // There is an active election that the user and the user has - // the correct access code. Therefore we activate the users - // account (allowing them to vote), and return the elction. - else { - return User.findByIdAndUpdate( - { _id: req.user._id }, - { active: true } - ).then(res.status(200).json(election)); - } + + // 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) => diff --git a/app/models/election.js b/app/models/election.js index 170a42a4..957470b5 100644 --- a/app/models/election.js +++ b/app/models/election.js @@ -112,7 +112,7 @@ electionSchema.methods.addAlternative = async function (alternative) { return savedAlternative; }; -electionSchema.methods.addVote = async function (user, priorities, accessCode) { +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(); diff --git a/client/controllers/generateUserCtrl.js b/client/controllers/generateUserCtrl.js index b950465b..2fc823fa 100644 --- a/client/controllers/generateUserCtrl.js +++ b/client/controllers/generateUserCtrl.js @@ -3,8 +3,6 @@ module.exports = [ 'userService', 'alertService', function ($scope, userService, alertService) { - $scope.generateUser = {}; - $scope.generateUser = function (email) { userService.generateUser({ email }).then( function (response) { From f6de604962182edd64f63182e51e9c01e9420d0d Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 18 Jan 2021 15:53:02 +0100 Subject: [PATCH 61/98] Don't return the user on generate, defeats the purpose of "secret" --- app/controllers/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index 4ef8d355..a06f26dd 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -59,7 +59,7 @@ exports.generate = (req, res) => { .then((createdUser) => mailHandler(userObject) .then(() => createdUser.getCleanUser()) - .then((user) => res.status(201).json(user)) + .then(() => res.sendStatus(201)) ) .catch(mongoose.Error.ValidationError, (err) => { throw new errors.ValidationError(err.errors); From 89fedbfa9a96009ee55b54fb28b7fc6ed8b7f1d9 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 18 Jan 2021 21:48:44 +0100 Subject: [PATCH 62/98] Implement email index as a schema. Set user as null when auth --- app.js | 1 + app/controllers/election.js | 4 ++-- app/controllers/user.js | 20 ++++++++++++----- app/digital/mail.js | 6 ++--- app/errors/index.js | 22 +++++++++++++++++++ app/models/email.js | 20 +++++++++++++++++ app/models/user.js | 6 ----- app/routes/auth.js | 10 +++------ app/views/partials/moderator/generateUser.pug | 5 ++++- client/controllers/generateUserCtrl.js | 11 +++++++--- 10 files changed, 77 insertions(+), 28 deletions(-) create mode 100644 app/models/email.js diff --git a/app.js b/app.js index 5234adbc..d425df40 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(); diff --git a/app/controllers/election.js b/app/controllers/election.js index d123cca7..be08e525 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -29,7 +29,7 @@ exports.retrieveActive = (req, res) => const { user, query } = req; // There is no active election (that the user has not voted on) if (!election) { - return res.sendStatus(404); + throw new errors.NotFoundError('election'); } // User is active, return the election @@ -43,7 +43,7 @@ exports.retrieveActive = (req, res) => !query.accessCode || election.accessCode !== Number(query.accessCode) ) { - return res.sendStatus(403); + throw new errors.AccessCodeError(); } // Active election and the inactive user has the correct access code. diff --git a/app/controllers/user.js b/app/controllers/user.js index a06f26dd..86b46f8d 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -1,5 +1,6 @@ const mongoose = require('mongoose'); const User = require('../models/user'); +const Email = require('../models/email'); const errors = require('../errors'); const errorChecks = require('../errors/error-checks'); @@ -43,23 +44,30 @@ exports.create = (req, res) => { }); }; -exports.generate = (req, res) => { +exports.generate = async (req, res) => { const username = short.generate(); const cardKey = short.generate(); - const password = crypto.randomBytes(10).toString('hex'); - const unActivatedEmail = req.body.email; + const password = crypto.randomBytes(15).toString('hex'); + const email = req.body.email; + + // Check that this email has not been allocated a user before + const check = await Email.findOne({ email }).exec(); + if (check) { + throw new errors.DuplicateEmailError(); + } + const userObject = { username, password, cardKey, - unActivatedEmail, }; const user = new User(userObject); return User.register(user, password) .then((createdUser) => - mailHandler(userObject) + mailHandler(userObject, email) .then(() => createdUser.getCleanUser()) - .then(() => res.sendStatus(201)) + .then(() => new Email({ email, user }).save()) + .then((email) => res.status(201).json(email.email)) ) .catch(mongoose.Error.ValidationError, (err) => { throw new errors.ValidationError(err.errors); diff --git a/app/digital/mail.js b/app/digital/mail.js index b9047437..d8497323 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -15,8 +15,8 @@ const transporter = nodemailer.createTransport({ }, }); -exports.mailHandler = async (user) => { - const { username, password, unActivatedEmail } = user; +exports.mailHandler = async (user, email) => { + const { username, password } = user; // Safe username and password const cleanUsername = username.replace(/\W/g, ''); const cleanPassword = password.replace(/\W/g, ''); @@ -26,7 +26,7 @@ exports.mailHandler = async (user) => { .replace('{{PASSWORD}}', cleanPassword); return transporter.sendMail({ from: `VOTE - Abakus <${creds.abakus_from_mail}>`, - to: `${cleanUsername} <${unActivatedEmail}>`, + to: `${email}`, subject: `VOTE Login Credentials`, text: `Username: ${cleanUsername}, Password: ${cleanPassword}`, html: html, diff --git a/app/errors/index.js b/app/errors/index.js index 805fd3bb..ee58c276 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -64,6 +64,17 @@ 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(); @@ -192,6 +203,17 @@ class DuplicateUsernameError extends Error { exports.DuplicateUsernameError = DuplicateUsernameError; +class DuplicateEmailError extends Error { + constructor() { + super(); + this.name = 'DuplicateEmailError'; + this.message = "There's already a user with this email."; + this.status = 400; + } +} + +exports.DuplicateEmailError = DuplicateEmailError; + exports.handleError = (res, err, status) => { const statusCode = status || err.status || 500; return res.status(statusCode).json( diff --git a/app/models/email.js b/app/models/email.js new file mode 100644 index 00000000..39430ac0 --- /dev/null +++ b/app/models/email.js @@ -0,0 +1,20 @@ +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const emailSchema = new Schema({ + email: { + type: String, + required: true, + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User', + }, + active: { + type: Boolean, + defeault: false, + }, +}); + +module.exports = mongoose.model('Email', emailSchema); diff --git a/app/models/user.js b/app/models/user.js index b11ca365..324069e4 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -35,12 +35,6 @@ const userSchema = new Schema({ required: true, unique: true, }, - // unActivatedEmail is used to by the digital implementation to ensure we can track - // down users that didn't get their initial user, so it an be deleted before recreating. - unActivatedEmail: { - type: String, - required: true, - }, }); userSchema.pre('save', function (next) { diff --git a/app/routes/auth.js b/app/routes/auth.js index aee14d42..1756cad7 100644 --- a/app/routes/auth.js +++ b/app/routes/auth.js @@ -2,6 +2,7 @@ const router = require('express-promise-router')(); const passport = require('passport'); const errors = require('../errors'); const User = require('../models/user'); +const Email = require('../models/email'); router.get('/login', (req, res) => { const csrfToken = process.env.NODE_ENV !== 'test' ? req.csrfToken() : 'test'; @@ -24,13 +25,8 @@ router.post( failureFlash: 'Brukernavn og/eller passord er feil.', }), async (req, res) => { - const { _id, unActivatedEmail } = req.user; - // If they can login with an unActivatedEmail user we'll set the user's - // unActivatedEmail to blank to show that this user has been able to login - if (unActivatedEmail) { - await User.findByIdAndUpdate({ _id: _id }, { unActivatedEmail: '' }); - } - + // Set the Email index.user to null for the spesific email + await Email.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/views/partials/moderator/generateUser.pug b/app/views/partials/moderator/generateUser.pug index d6cde387..ed14179a 100644 --- a/app/views/partials/moderator/generateUser.pug +++ b/app/views/partials/moderator/generateUser.pug @@ -10,4 +10,7 @@ ng-model='email' ) - button#submit.btn.btn-default.btn-lg(type='submit') Generer bruker + button#submit.btn.btn-default.btn-lg( + type='submit', + ng-disabled='pending' + ) Generer bruker diff --git a/client/controllers/generateUserCtrl.js b/client/controllers/generateUserCtrl.js index 2fc823fa..a97f6447 100644 --- a/client/controllers/generateUserCtrl.js +++ b/client/controllers/generateUserCtrl.js @@ -4,16 +4,21 @@ module.exports = [ 'alertService', function ($scope, userService, alertService) { $scope.generateUser = function (email) { + $scope.pending = true; userService.generateUser({ email }).then( function (response) { - alertService.addSuccess('Bruker generert!'); + alertService.addSuccess( + `Bruker laget for ${response.data} generert!` + ); $scope.email = ''; + $scope.pending = false; }, function (response) { + $scope.pending = false; switch (response.data.name) { - case 'DuplicateUsernameError': + case 'DuplicateEmailError': alertService.addError( - 'Dette ntnu-brukernavnet er allerede registrert.' + 'Denne eposten har allerede fått en bruker.' ); break; case 'DuplicateCardError': From 53b04b680f8959bc56b0b977d336547ccc8aa2d4 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 19 Jan 2021 21:23:02 +0100 Subject: [PATCH 63/98] Add unique: true --- app/controllers/user.js | 6 +++--- app/models/{email.js => register.js} | 5 +++-- app/routes/auth.js | 5 ++--- 3 files changed, 8 insertions(+), 8 deletions(-) rename app/models/{email.js => register.js} (68%) diff --git a/app/controllers/user.js b/app/controllers/user.js index 86b46f8d..66e89ee6 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -1,6 +1,6 @@ const mongoose = require('mongoose'); const User = require('../models/user'); -const Email = require('../models/email'); +const Register = require('../models/register'); const errors = require('../errors'); const errorChecks = require('../errors/error-checks'); @@ -51,7 +51,7 @@ exports.generate = async (req, res) => { const email = req.body.email; // Check that this email has not been allocated a user before - const check = await Email.findOne({ email }).exec(); + const check = await Register.findOne({ email }).exec(); if (check) { throw new errors.DuplicateEmailError(); } @@ -66,7 +66,7 @@ exports.generate = async (req, res) => { .then((createdUser) => mailHandler(userObject, email) .then(() => createdUser.getCleanUser()) - .then(() => new Email({ email, user }).save()) + .then(() => new Register({ email, user }).save()) .then((email) => res.status(201).json(email.email)) ) .catch(mongoose.Error.ValidationError, (err) => { diff --git a/app/models/email.js b/app/models/register.js similarity index 68% rename from app/models/email.js rename to app/models/register.js index 39430ac0..b686c47b 100644 --- a/app/models/email.js +++ b/app/models/register.js @@ -2,10 +2,11 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; -const emailSchema = new Schema({ +const registerSchema = new Schema({ email: { type: String, required: true, + unique: true, }, user: { type: Schema.Types.ObjectId, @@ -17,4 +18,4 @@ const emailSchema = new Schema({ }, }); -module.exports = mongoose.model('Email', emailSchema); +module.exports = mongoose.model('Register', registerSchema); diff --git a/app/routes/auth.js b/app/routes/auth.js index 1756cad7..418ad4a9 100644 --- a/app/routes/auth.js +++ b/app/routes/auth.js @@ -1,8 +1,7 @@ const router = require('express-promise-router')(); const passport = require('passport'); const errors = require('../errors'); -const User = require('../models/user'); -const Email = require('../models/email'); +const Register = require('../models/register'); router.get('/login', (req, res) => { const csrfToken = process.env.NODE_ENV !== 'test' ? req.csrfToken() : 'test'; @@ -26,7 +25,7 @@ router.post( }), async (req, res) => { // Set the Email index.user to null for the spesific email - await Email.findOneAndUpdate({ user: req.user._id }, { user: null }); + 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 || '/'; From 748225d2ce892ef52df86ec6126833db22f80ff4 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Tue, 19 Jan 2021 21:54:25 +0100 Subject: [PATCH 64/98] Only send mail in prod (by default) --- app/digital/mail.js | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/app/digital/mail.js b/app/digital/mail.js index d8497323..2383ad45 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -1,19 +1,24 @@ const nodemailer = require('nodemailer'); +const env = require('../../env'); const fs = require('fs'); const path = require('path'); -const creds = require('./client_secret.json'); -const transporter = nodemailer.createTransport({ - host: 'smtp.gmail.com', - port: 465, - secure: true, - auth: { - type: 'OAuth2', - user: creds.abakus_from_mail, - serviceClient: creds.client_id, - privateKey: creds.private_key, - }, -}); +let creds = {}; +let transporter = {}; +if (env.NODE_ENV === 'production') { + creds = require('./client_secret.json'); + transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 465, + secure: true, + auth: { + type: 'OAuth2', + user: creds.abakus_from_mail, + serviceClient: creds.client_id, + privateKey: creds.private_key, + }, + }); +} exports.mailHandler = async (user, email) => { const { username, password } = user; @@ -24,6 +29,20 @@ exports.mailHandler = async (user, email) => { .readFileSync(path.resolve(__dirname, './template.html'), 'utf8') .replace('{{USERNAME}}', cleanUsername) .replace('{{PASSWORD}}', cleanPassword); + + if (env.NODE_ENV === 'development') { + console.log('MAIL IS NOT SENT IN DEVELOPMENT'); // eslint-disable-line no-console + return new Promise(function (res, rej) { + console.log('username:', cleanUsername, 'password:', cleanPassword); // eslint-disable-line no-console + res(''); + }) + .then(function (succ) { + return succ; + }) + .then(function (err) { + return err; + }); + } return transporter.sendMail({ from: `VOTE - Abakus <${creds.abakus_from_mail}>`, to: `${email}`, From 635ea5a396f9b86ee19154458e4ed3c3e4b99291 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Wed, 20 Jan 2021 16:22:27 +0100 Subject: [PATCH 65/98] Bumb node, mongo, redis and fix env for auth in prod --- .drone.yml | 14 +++++++------- Dockerfile | 2 +- app/digital/mail.js | 5 +++-- docker-compose.yml | 4 ++-- env.js | 2 ++ 5 files changed, 15 insertions(+), 12 deletions(-) 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/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/app/digital/mail.js b/app/digital/mail.js index 2383ad45..8df007e6 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -6,7 +6,8 @@ const path = require('path'); let creds = {}; let transporter = {}; if (env.NODE_ENV === 'production') { - creds = require('./client_secret.json'); + // 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, @@ -32,7 +33,7 @@ exports.mailHandler = async (user, email) => { if (env.NODE_ENV === 'development') { console.log('MAIL IS NOT SENT IN DEVELOPMENT'); // eslint-disable-line no-console - return new Promise(function (res, rej) { + return new Promise(function (res, _) { console.log('username:', cleanUsername, 'password:', cleanPassword); // eslint-disable-line no-console res(''); }) diff --git a/docker-compose.yml b/docker-compose.yml index 72beaa12..4d6fb195 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,10 +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..928eb83d 100644 --- a/env.js +++ b/env.js @@ -12,4 +12,6 @@ module.exports = { RAVEN_DSN: process.env.RAVEN_DSN, MONGO_URL: process.env.MONGO_URL || 'mongodb://localhost:27017/vote', REDIS_URL: process.env.REDIS_URL || 'localhost', + // Mail auth + GOOGLE_AUTH: process.env.GOOGLE_AUTH || '', }; From 8baa6480a15c1f80bc6022c265886a95b060b41e Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 23 Jan 2021 15:03:54 +0100 Subject: [PATCH 66/98] Use LEGOUser to avoid duplicates, resend mail on duplicate --- app/controllers/user.js | 48 ++++++++--- app/digital/mail.js | 81 +++++++++++++------ app/digital/template.html | 13 ++- app/errors/index.js | 8 +- app/models/register.js | 9 ++- app/views/partials/moderator/generateUser.pug | 13 ++- client/controllers/generateUserCtrl.js | 13 +-- client/services/userService.js | 4 +- env.js | 6 +- package.json | 1 + yarn.lock | 24 +++++- 11 files changed, 154 insertions(+), 66 deletions(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index 66e89ee6..c2ffbb94 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -45,29 +45,53 @@ exports.create = (req, res) => { }; exports.generate = async (req, res) => { - const username = short.generate(); - const cardKey = short.generate(); - const password = crypto.randomBytes(15).toString('hex'); - const email = req.body.email; + const { legoUser, email } = req.body; + + if (!legoUser || !email) { + throw new errors.InvalidPayloadError('Params legoUser or email provided.'); + } + + // Try to fetch an entry from the register with this username + const entry = await Register.findOne({ legoUser }).exec(); - // Check that this email has not been allocated a user before - const check = await Register.findOne({ email }).exec(); - if (check) { - throw new errors.DuplicateEmailError(); + // Entry has no user this user is allready activated + if (entry && !entry.user) { + return mailHandler('reject', { email }); } + 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(legoUser)) + .catch((err) => res.status(500).json(err)) + ); + } + + // The user does not exist, so we generate as usual + const username = short.generate(); + const cardKey = short.generate(); const userObject = { username, password, cardKey, }; + const user = new User(userObject); return User.register(user, password) .then((createdUser) => - mailHandler(userObject, email) - .then(() => createdUser.getCleanUser()) - .then(() => new Register({ email, user }).save()) - .then((email) => res.status(201).json(email.email)) + mailHandler('send', { email, username: createdUser.username, password }) + .then(() => new Register({ legoUser, email, user }).save()) + .then(() => res.status(201).json(legoUser)) + .catch((err) => res.status(500).json(err)) ) .catch(mongoose.Error.ValidationError, (err) => { throw new errors.ValidationError(err.errors); diff --git a/app/digital/mail.js b/app/digital/mail.js index 8df007e6..69a014b7 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -2,9 +2,13 @@ const nodemailer = require('nodemailer'); const env = require('../../env'); const fs = require('fs'); const path = require('path'); +const handlebars = require('handlebars'); let creds = {}; let transporter = {}; +let from = ''; + +// Mail transporter object for production Google mail if (env.NODE_ENV === 'production') { // Get google auth creds from env creds = JSON.parse(Buffer.from(env.GOOGLE_AUTH, 'base64').toString()); @@ -19,36 +23,65 @@ if (env.NODE_ENV === 'production') { privateKey: creds.private_key, }, }); + from = `VOTE - Abakus <${creds.abakus_from_mail}>`; +} + +// Mail transporter object for dev Ethereal mail +if (env.NODE_ENV === 'development' && env.ETHEREAL) { + const [user, pass] = env.ETHEREAL.split(':'); + transporter = nodemailer.createTransport({ + host: 'smtp.ethereal.email', + port: 587, + auth: { + user, + pass, + }, + }); + from = `VOTE(TEST) - Abakus <${user}>`; } -exports.mailHandler = async (user, email) => { - const { username, password } = user; - // Safe username and password - const cleanUsername = username.replace(/\W/g, ''); - const cleanPassword = password.replace(/\W/g, ''); - const html = fs - .readFileSync(path.resolve(__dirname, './template.html'), 'utf8') - .replace('{{USERNAME}}', cleanUsername) - .replace('{{PASSWORD}}', cleanPassword); +exports.mailHandler = async (action, data) => { + const html = fs.readFileSync( + path.resolve(__dirname, './template.html'), + 'utf8' + ); + const template = handlebars.compile(html); + const { email, username, password } = data; - if (env.NODE_ENV === 'development') { - console.log('MAIL IS NOT SENT IN DEVELOPMENT'); // eslint-disable-line no-console - return new Promise(function (res, _) { - console.log('username:', cleanUsername, 'password:', cleanPassword); // eslint-disable-line no-console - res(''); - }) - .then(function (succ) { - return succ; - }) - .then(function (err) { - return err; - }); + let replacements = {}; + switch (action) { + case 'reject': + replacements = { + title: 'Allerede aktivert bruker', + description: + 'Du har allerede motatt en bruker, og vi har registrert at du har klart å logge inn.', + }; + break; + case 'resend': + replacements = { + title: 'Velkommen til Genfors', + description: + 'Dette er din digitale bruker. Dette er den samme brukeren, men med nytt generert passord.', + username: username.replace(/\W/g, ''), + password: password.replace(/\W/g, ''), + }; + break; + case 'send': + replacements = { + title: 'Velkommen til Genfors', + description: + 'Dette er din digital bruker til stemmesystemet VOTE. Mer info kommer på Genfors.', + username: username.replace(/\W/g, ''), + password: password.replace(/\W/g, ''), + }; + break; } + return transporter.sendMail({ - from: `VOTE - Abakus <${creds.abakus_from_mail}>`, + from, to: `${email}`, subject: `VOTE Login Credentials`, - text: `Username: ${cleanUsername}, Password: ${cleanPassword}`, - html: html, + text: `Username: ${username}, Password: ${password}`, + html: template(replacements), }); }; diff --git a/app/digital/template.html b/app/digital/template.html index 3ac9dcc2..5fb3f718 100644 --- a/app/digital/template.html +++ b/app/digital/template.html @@ -250,16 +250,13 @@ VOTE -

Velkommen til Digital Genfors 2021!

+

{{title}}

-

Hei! Vi ser du har meldt deg på Genfors 2021 på abakus.no.

-

I år blir denne digital og vi har derfor laget en bruker i VOTE for deg. -

Brukernavn: {{USERNAME}}

-

Passord: {{PASSWORD}}

+

{{description}}

+
+

{{username}}

+

{{password}}


-

Hvis du ikke har vært på Genfors før og er usikker på hva dette betyr går det fint, da det vil bli forklart på Genfors.

-

Ditt brukernavn og passord har blitt generert av VOTE, og vi i Webkom vet ikke hva det er.

-

Det er derfor viktig at du passer godt på de og ikke deler de med noen.

Logg inn her diff --git a/app/errors/index.js b/app/errors/index.js index ee58c276..2f1823e7 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -203,16 +203,16 @@ class DuplicateUsernameError extends Error { exports.DuplicateUsernameError = DuplicateUsernameError; -class DuplicateEmailError extends Error { +class DuplicateLegoUserError extends Error { constructor() { super(); - this.name = 'DuplicateEmailError'; - this.message = "There's already a user with this email."; + this.name = 'DuplicateLegoUserError'; + this.message = 'This LEGO user has allready gotten a user.'; this.status = 400; } } -exports.DuplicateEmailError = DuplicateEmailError; +exports.DuplicateLegoUserError = DuplicateLegoUserError; exports.handleError = (res, err, status) => { const statusCode = status || err.status || 500; diff --git a/app/models/register.js b/app/models/register.js index b686c47b..7eccffca 100644 --- a/app/models/register.js +++ b/app/models/register.js @@ -3,6 +3,11 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; const registerSchema = new Schema({ + legoUser: { + type: String, + required: true, + unique: true, + }, email: { type: String, required: true, @@ -12,10 +17,6 @@ const registerSchema = new Schema({ type: Schema.Types.ObjectId, ref: 'User', }, - active: { - type: Boolean, - defeault: false, - }, }); module.exports = mongoose.model('Register', registerSchema); diff --git a/app/views/partials/moderator/generateUser.pug b/app/views/partials/moderator/generateUser.pug index ed14179a..a76ecf31 100644 --- a/app/views/partials/moderator/generateUser.pug +++ b/app/views/partials/moderator/generateUser.pug @@ -1,13 +1,22 @@ .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(email)', name='generateUserForm') + form.form-group(ng-submit='generateUser(user)', name='generateUserForm') + .form-group + label LEGO User + input.form-control( + type='text', + name='legoUser', + placeholder='Skriv inn LEGO User', + ng-model='user.legoUser' + ) + .form-group label Epost input.form-control( type='text', name='email', placeholder='Skriv inn epost', - ng-model='email' + ng-model='user.email' ) button#submit.btn.btn-default.btn-lg( diff --git a/client/controllers/generateUserCtrl.js b/client/controllers/generateUserCtrl.js index a97f6447..413afea7 100644 --- a/client/controllers/generateUserCtrl.js +++ b/client/controllers/generateUserCtrl.js @@ -3,22 +3,23 @@ module.exports = [ 'userService', 'alertService', function ($scope, userService, alertService) { - $scope.generateUser = function (email) { + $scope.generateUser = function (user) { + $scope.user = {}; $scope.pending = true; - userService.generateUser({ email }).then( + userService.generateUser(user).then( function (response) { alertService.addSuccess( - `Bruker laget for ${response.data} generert!` + `Bruker generert/oppdatert for ${response.data}!` ); - $scope.email = ''; + $scope.user = {}; $scope.pending = false; }, function (response) { $scope.pending = false; switch (response.data.name) { - case 'DuplicateEmailError': + case 'DuplicateLegoUserError': alertService.addError( - 'Denne eposten har allerede fått en bruker.' + 'Denne LEGO brukern har allerede fått en bruker.' ); break; case 'DuplicateCardError': diff --git a/client/services/userService.js b/client/services/userService.js index d2e5e1de..222546d8 100644 --- a/client/services/userService.js +++ b/client/services/userService.js @@ -9,8 +9,8 @@ module.exports = [ return $http.post('/api/user', user); }; - this.generateUser = function (email) { - return $http.post('/api/user/generate', email); + this.generateUser = function (user) { + return $http.post('/api/user/generate', user); }; this.changeCard = function (user) { diff --git a/env.js b/env.js index 928eb83d..5fb4dfbd 100644 --- a/env.js +++ b/env.js @@ -8,10 +8,10 @@ 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', // Mail auth - GOOGLE_AUTH: process.env.GOOGLE_AUTH || '', + GOOGLE_AUTH: process.env.GOOGLE_AUTH, + // Dev mail auth + ETHEREAL: process.env.ETHEREAL, }; diff --git a/package.json b/package.json index b8122205..24a3591b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "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", diff --git a/yarn.lock b/yarn.lock index 98fe3bc1..0fd4eae1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3608,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" @@ -5255,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== @@ -7777,6 +7789,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" @@ -8183,6 +8200,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" From 4cc1afb3a04b74ba79949f3dff7333a52a6a007d Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 23 Jan 2021 17:10:31 +0100 Subject: [PATCH 67/98] Update README --- README.md | 52 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a752ab0b..f9d98748 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 ``` @@ -52,19 +54,29 @@ $ yarn start - `COOKIE_SECRET` - **IMPORTANT** to change this to a secret value in production!! - `default`: in dev: `localsecret`, otherwise empty +- `GOOGLE_AUTH` + - A base64 encoded string with the json data of a service account that can send mail. We also store + the `abakus_from_email` in the data object. Note that the `GOOGLE_AUTH` variable is only used when + VOTE is running in production, in development the `ETHERAL` variable can be used. +- `ETHEREAL` + - A optional variable you can set that allows emails to be routed to a test `smtp` server. This is + useful if you intend to make changes to the way emails are sent, or the way the template looks. + The variable must be on the format `user:pass`, that you can find [here](https://ethereal.email/create). -See `app.js` for the rest +See `app.js` and `env.js` for the rest ### Production +An example deployment can be found in the `./deployment` folder. + ```bash $ yarn build -$ LOGO_SRC=https://my-domain.tld/logo.png NODE_ENV=production yarn start +$ LOGO_SRC=https://my-domain.tld/logo.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 +117,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. From 23cdf79d8ed6888df151c2f014d4eb6793946b8d Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 23 Jan 2021 17:20:16 +0100 Subject: [PATCH 68/98] Generated users default to active:false --- app/controllers/user.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/user.js b/app/controllers/user.js index c2ffbb94..44e582d7 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -83,9 +83,11 @@ exports.generate = async (req, res) => { username, password, cardKey, + active: false, }; const user = new User(userObject); + console.log(userObject, user); return User.register(user, password) .then((createdUser) => mailHandler('send', { email, username: createdUser.username, password }) From 0e99a93e4970f1ff4bde7e0bb4c501f75c19b116 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 23 Jan 2021 17:24:49 +0100 Subject: [PATCH 69/98] Send DuplicateLegoUserError on reject --- app/controllers/user.js | 6 +++++- app/errors/index.js | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index 44e582d7..f7d71c29 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -56,7 +56,11 @@ exports.generate = async (req, res) => { // Entry has no user this user is allready activated if (entry && !entry.user) { - return mailHandler('reject', { email }); + return mailHandler('reject', { email }) + .then(() => { + throw new errors.DuplicateLegoUserError(); + }) + .catch((err) => res.status(500).json(err)); } const password = crypto.randomBytes(11).toString('hex'); diff --git a/app/errors/index.js b/app/errors/index.js index 2f1823e7..6fd8fd24 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -208,7 +208,7 @@ class DuplicateLegoUserError extends Error { super(); this.name = 'DuplicateLegoUserError'; this.message = 'This LEGO user has allready gotten a user.'; - this.status = 400; + this.status = 409; } } From a61750cfe0b0898eec013a06f12939bfd1c683ec Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 24 Jan 2021 13:40:38 +0100 Subject: [PATCH 70/98] Apply suggestions from code review Co-authored-by: Odin Ugedal --- app/controllers/user.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index f7d71c29..99507974 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -45,7 +45,7 @@ exports.create = (req, res) => { }; exports.generate = async (req, res) => { - const { legoUser, email } = req.body; + const { legoUser, email, ignoreExistingUser } = req.body; if (!legoUser || !email) { throw new errors.InvalidPayloadError('Params legoUser or email provided.'); @@ -53,6 +53,10 @@ exports.generate = async (req, res) => { // Try to fetch an entry from the register with this username const entry = await Register.findOne({ legoUser }).exec(); + + if (entry && ignoreExistingUser) { + return res.status(409).json(legoUser); + } // Entry has no user this user is allready activated if (entry && !entry.user) { From 9110d1d326748a42347bba807d4c9ca6b3a19b05 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 24 Jan 2021 13:41:15 +0100 Subject: [PATCH 71/98] Load "from mail" from env and not json --- app/controllers/user.js | 1 - app/digital/mail.js | 5 +++-- env.js | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index 99507974..d58c3ede 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -95,7 +95,6 @@ exports.generate = async (req, res) => { }; const user = new User(userObject); - console.log(userObject, user); return User.register(user, password) .then((createdUser) => mailHandler('send', { email, username: createdUser.username, password }) diff --git a/app/digital/mail.js b/app/digital/mail.js index 69a014b7..292a7dee 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -18,16 +18,17 @@ if (env.NODE_ENV === 'production') { secure: true, auth: { type: 'OAuth2', - user: creds.abakus_from_mail, + user: process.env.GOOGLE_FROM_MAIL, serviceClient: creds.client_id, privateKey: creds.private_key, }, }); - from = `VOTE - Abakus <${creds.abakus_from_mail}>`; + from = `VOTE - Abakus <${process.env.GOOGLE_FROM_MAIL}>`; } // Mail transporter object for dev Ethereal mail if (env.NODE_ENV === 'development' && env.ETHEREAL) { + // The ethereal string should be on the format "user:pass" const [user, pass] = env.ETHEREAL.split(':'); transporter = nodemailer.createTransport({ host: 'smtp.ethereal.email', diff --git a/env.js b/env.js index 5fb4dfbd..c6e606d0 100644 --- a/env.js +++ b/env.js @@ -12,6 +12,7 @@ module.exports = { REDIS_URL: process.env.REDIS_URL || 'localhost', // Mail auth GOOGLE_AUTH: process.env.GOOGLE_AUTH, + GOOGLE_FROM_MAIL: process.env.GOOGLE_FROM_MAIL || '', // Dev mail auth ETHEREAL: process.env.ETHEREAL, }; From 016bc3bad2f97f97c13452946e22a1030c1022cb Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Sun, 31 Jan 2021 19:33:46 +0100 Subject: [PATCH 72/98] Update deployment docs --- README.md | 2 +- deployment/README.md | 33 +++++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f9d98748..d3f6c8d2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ See `app.js` and `env.js` for the rest ### Production -An example deployment can be found in the `./deployment` folder. +> For a production deployment example, see [deployment](./deployment/README.md) in the `deployment` folder ```bash $ yarn build 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. From b3eea0c4f0d9130511ffe72d74de178ab7667229 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Sun, 31 Jan 2021 20:40:09 +0100 Subject: [PATCH 73/98] Update example docker-compose file --- deployment/docker-compose.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index c2ec1fa6..824a9e07 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: @@ -13,5 +13,10 @@ services: 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' From bb77d67f5190539d86a1eba57fc376a598de75b3 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Sun, 31 Jan 2021 12:50:33 +0100 Subject: [PATCH 74/98] Clear selected priorities on vote and fetchElection --- client/controllers/electionCtrl.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/controllers/electionCtrl.js b/client/controllers/electionCtrl.js index d39bdd6c..7c6ebc93 100644 --- a/client/controllers/electionCtrl.js +++ b/client/controllers/electionCtrl.js @@ -28,6 +28,7 @@ module.exports = [ function getActiveElection(accessCode) { return electionService.getActiveElection(accessCode).then( function (response) { + $scope.priorities = []; $scope.electionExists = true; $scope.activeElection = response.data; $scope.correctCode = true; @@ -104,6 +105,7 @@ module.exports = [ function (response) { $window.scrollTo(0, 0); $scope.activeElection = null; + $scope.priorities = []; $scope.electionExists = false; $scope.confirmVote = false; $scope.correctCode = false; From b5d76228ad10ade096c5c8b317cf79bbffaebd06 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Sun, 31 Jan 2021 13:52:21 +0100 Subject: [PATCH 75/98] Initialize counts obj in STV with all alternatives --- app/controllers/user.js | 2 +- app/stv/stv.js | 2 +- app/stv/stv.ts | 12 +++++++++++- package.json | 3 ++- yarn.lock | 5 +++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index d58c3ede..2114235f 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -53,7 +53,7 @@ exports.generate = async (req, res) => { // Try to fetch an entry from the register with this username const entry = await Register.findOne({ legoUser }).exec(); - + if (entry && ignoreExistingUser) { return res.status(409).json(legoUser); } diff --git a/app/stv/stv.js b/app/stv/stv.js index dbf110da..d61c9cf3 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -44,7 +44,7 @@ exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats = 1, use while (votes.length > 0 && iteration < 100) { iteration += 1; votes = votes.filter((vote) => vote.priorities.length > 0); - const counts = {}; + 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]); diff --git a/app/stv/stv.ts b/app/stv/stv.ts index 8a548660..5b4ccdbd 100644 --- a/app/stv/stv.ts +++ b/app/stv/stv.ts @@ -22,6 +22,10 @@ type Alternative = { election: string; }; +type STVCounts = { + [key: string]: number; +}; + type Vote = { _id: string; priorities: Alternative[]; @@ -167,7 +171,13 @@ exports.calculateWinnerUsingSTV = ( votes = votes.filter((vote: Vote) => vote.priorities.length > 0); // Dict with the counts for each candidate - const counts: { [key: string]: number } = {}; + const counts: STVCounts = alternatives.reduce( + (counts: STVCounts, alternative: Alternative) => ({ + ...counts, + [alternative.description]: 0, + }), + {} + ); for (const i in votes) { // The vote for this loop diff --git a/package.json b/package.json index 24a3591b..a47506a7 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint:eslint": "eslint . --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", + "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", @@ -98,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/yarn.lock b/yarn.lock index 0fd4eae1..84727870 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7774,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" From 54e6441fb50c3403c7d5431184cd2931f4c863df Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Sun, 31 Jan 2021 15:11:01 +0100 Subject: [PATCH 76/98] Add blank votes to election summary --- app/stv/stv.js | 3 ++ app/stv/stv.ts | 8 ++++++ app/views/partials/admin/editElection.pug | 19 +++++++++---- client/styles/admin.styl | 6 +++- client/styles/main.styl | 34 +++++++++++++++++++++++ 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/stv/stv.js b/app/stv/stv.js index d61c9cf3..2727f467 100644 --- a/app/stv/stv.js +++ b/app/stv/stv.js @@ -39,6 +39,7 @@ exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats = 1, use 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) { @@ -91,6 +92,7 @@ exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats = 1, use thr, seats, voteCount: inputVotes.length, + blankVoteCount, useStrict, }; } @@ -178,6 +180,7 @@ exports.calculateWinnerUsingSTV = (inputVotes, inputAlternatives, seats = 1, use thr, seats, voteCount: inputVotes.length, + blankVoteCount, useStrict, }; }; diff --git a/app/stv/stv.ts b/app/stv/stv.ts index 5b4ccdbd..7175cd74 100644 --- a/app/stv/stv.ts +++ b/app/stv/stv.ts @@ -13,6 +13,7 @@ type STV = { thr: number; seats: number; voteCount: number; + blankVoteCount: number; useStrict: boolean; }; @@ -159,6 +160,11 @@ exports.calculateWinnerUsingSTV = ( // 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[] = []; @@ -263,6 +269,7 @@ exports.calculateWinnerUsingSTV = ( thr, seats, voteCount: inputVotes.length, + blankVoteCount, useStrict, }; } @@ -413,6 +420,7 @@ exports.calculateWinnerUsingSTV = ( thr, seats, voteCount: inputVotes.length, + blankVoteCount, useStrict, }; }; diff --git a/app/views/partials/admin/editElection.pug b/app/views/partials/admin/editElection.pug index 1a6ce597..484d31df 100644 --- a/app/views/partials/admin/editElection.pug +++ b/app/views/partials/admin/editElection.pug @@ -63,15 +63,24 @@ h2 Oppsummering table.table.mono tbody - tr + tr th.th-left Stemmer th.th-right = {{ election.voteCount }} - tr + tr + th.th-left ∟ Hvorav blanke stemmer + th.th-right = {{ election.blankVoteCount }} + tr th.th-left Plasser th.th-right = {{ election.seats }} - tr + tr th.th-left Terskel - th.th-right ⌊{{ election.voteCount }}/{{ election.seats + 1 }}⌋ + 1 = {{ election.thr }} + th.th-right ⌊ + span.cs-tooltip {{ election.voteCount }} + span.cs-tooltiptext Antall stemmer + span / + span.cs-tooltip {{ election.seats + 1 }} + span.cs-tooltiptext Antall stemmer + 1 + span ⌋ + 1 = {{ election.thr }} h2 Logg ul.list-unstyled.log.mono li(ng-repeat='elem in election.log')(ng-switch='elem.action') @@ -93,7 +102,7 @@ hr h2 Resultat div(ng-class='\'alert-\' + election.status') {{ election.result.status }} - table.table.mono + table.table.mono.large(style='margin-bottom: 100px;') tbody tr(ng-repeat='winner in election.result.winners') th.th-right Vinner {{ $index + 1 }}: diff --git a/client/styles/admin.styl b/client/styles/admin.styl index 04d502f2..4f5fd7be 100644 --- a/client/styles/admin.styl +++ b/client/styles/admin.styl @@ -96,6 +96,10 @@ form.add-alternative .toggle-show margin-left 0 +.large + font-size 25px + text-align center + .big font-size 50px text-align center @@ -137,7 +141,7 @@ form.add-alternative background-color alpha(#000080, 0.15) p margin 0 - font-size 12px + font-size 16px line-height 20px h5 margin 5px diff --git a/client/styles/main.styl b/client/styles/main.styl index 7105638d..bbef2f41 100644 --- a/client/styles/main.styl +++ b/client/styles/main.styl @@ -170,5 +170,39 @@ label 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' From e1dc206805b923b4767f2bfb7075140d3438c0a5 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Tue, 2 Feb 2021 23:05:48 +0100 Subject: [PATCH 77/98] Add datasets and tests with blank votes --- test/stv/blank.test.js | 139 +++++++++++++++++++++++++++++++++ test/stv/datasets/dataset11.js | 17 ++++ test/stv/datasets/dataset12.js | 26 ++++++ test/stv/datasets/dataset13.js | 26 ++++++ test/stv/datasets/index.js | 3 + 5 files changed, 211 insertions(+) create mode 100644 test/stv/blank.test.js create mode 100644 test/stv/datasets/dataset11.js create mode 100644 test/stv/datasets/dataset12.js create mode 100644 test/stv/datasets/dataset13.js 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/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/index.js b/test/stv/datasets/index.js index 46f1d983..e89d8601 100644 --- a/test/stv/datasets/index.js +++ b/test/stv/datasets/index.js @@ -10,4 +10,7 @@ module.exports = { dataset8: require('./dataset8'), dataset9: require('./dataset9'), dataset10: require('./dataset10'), + dataset11: require('./dataset11'), + dataset12: require('./dataset12'), + dataset13: require('./dataset13'), }; From 23913fb66933511117e59e4e23a740b9e81dae00 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Mon, 1 Feb 2021 19:55:49 +0100 Subject: [PATCH 78/98] AccessCode was random for the schema, not the objects --- app/models/election.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/election.js b/app/models/election.js index 957470b5..0bdcbf6a 100644 --- a/app/models/election.js +++ b/app/models/election.js @@ -55,7 +55,8 @@ const electionSchema = new Schema({ }, accessCode: { type: Number, - default: Math.floor(Math.random() * (10000 - 1000) + 1000), + // https://mongoosejs.com/docs/defaults.html#default-functions + default: () => Math.floor(Math.random() * 9000 + 1000), }, }); From 35ef0dc277d7ab7bbd8d697187656274e191491b Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Wed, 3 Feb 2021 13:10:12 +0100 Subject: [PATCH 79/98] Fix edit election page to match new alternatives styles --- app/views/partials/admin/editElection.pug | 11 +++++++++-- client/controllers/editElectionCtrl.js | 16 ++++++++++++++++ client/styles/admin.styl | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/views/partials/admin/editElection.pug b/app/views/partials/admin/editElection.pug index 484d31df..9b32a5d4 100644 --- a/app/views/partials/admin/editElection.pug +++ b/app/views/partials/admin/editElection.pug @@ -3,7 +3,12 @@ .election-info.admin h2 {{ election.title }} p {{ election.description }} - h3 Tilgangskode: {{ election.accessCode }} + 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 @@ -22,7 +27,9 @@ ul.list-unstyled li(ng-repeat='alternative in election.alternatives') - p {{ alternative.description }} + .content + div + p {{ alternative.description }} form.add-alternative.form-group( name='alternativeForm', diff --git a/client/controllers/editElectionCtrl.js b/client/controllers/editElectionCtrl.js index 70bff383..d680d75c 100644 --- a/client/controllers/editElectionCtrl.js +++ b/client/controllers/editElectionCtrl.js @@ -139,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/styles/admin.styl b/client/styles/admin.styl index 4f5fd7be..338d1d3d 100644 --- a/client/styles/admin.styl +++ b/client/styles/admin.styl @@ -56,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 @@ -67,6 +80,7 @@ form &:hover cursor default background-color alpha($alternative-background, 0.15) + box-shadow none span position absolute From b27b626576123a50396e6cc55075b4763ca07ce8 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Wed, 3 Feb 2021 12:36:05 +0100 Subject: [PATCH 80/98] Reuse ballot confirmatin on receipt page --- app/views/partials/retrieveVote.pug | 16 ++++++++++------ client/styles/election.styl | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/views/partials/retrieveVote.pug b/app/views/partials/retrieveVote.pug index b48167af..559444d4 100644 --- a/app/views/partials/retrieveVote.pug +++ b/app/views/partials/retrieveVote.pug @@ -20,9 +20,13 @@ ) Hent avstemning .text-center.vote-result-feedback(ng-if='vote') - h4 Din prioritering på: {{ vote.election.title }} - table.table.mono - tbody - tr(ng-repeat='priority in vote.priorities') - th.th-right Prioritering {{ $index + 1 }}: - th.th-left {{ priority.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/styles/election.styl b/client/styles/election.styl index 912f7ba2..46dcdc92 100644 --- a/client/styles/election.styl +++ b/client/styles/election.styl @@ -151,6 +151,7 @@ line-height 40px p + text-transform uppercase vertical-align middle margin 0 From 3ad2d3215617a07c6f7cc51cd34f4b601d25fce1 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 31 Jan 2021 17:28:45 +0100 Subject: [PATCH 81/98] Only allow one active election --- app/controllers/election.js | 9 +++++++-- app/errors/index.js | 11 +++++++++++ test/api/election.test.js | 18 +++++++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/controllers/election.js b/app/controllers/election.js index be08e525..9608bda8 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -96,12 +96,17 @@ 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.AllreadyActiveElectionError(); + } + 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) => { diff --git a/app/errors/index.js b/app/errors/index.js index 6fd8fd24..7b5aed82 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -214,6 +214,17 @@ class DuplicateLegoUserError extends Error { exports.DuplicateLegoUserError = DuplicateLegoUserError; +class AllreadyActiveElectionError extends Error { + constructor() { + super(); + this.name = 'AllreadyActiveElection'; + this.message = 'There is allready an active election'; + this.status = 409; + } +} + +exports.AllreadyActiveElectionError = AllreadyActiveElectionError; + exports.handleError = (res, err, status) => { const statusCode = status || err.status || 500; return res.status(statusCode).json( diff --git a/test/api/election.test.js b/test/api/election.test.js index 5f402b88..79fab966 100644 --- a/test/api/election.test.js +++ b/test/api/election.test.js @@ -349,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`) From 71aaf731599fb8543f8b8bfa7dfc062131039d17 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 31 Jan 2021 15:20:33 +0100 Subject: [PATCH 82/98] Improve the mail template with more env usage --- README.md | 9 ++++-- app.js | 4 +-- app/digital/mail.js | 48 ++++++++++++++++++---------- app/digital/template.html | 67 +++++++++++++++++---------------------- app/views/layout.pug | 2 +- env.js | 7 +++- 6 files changed, 75 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index d3f6c8d2..a8b47b0a 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,12 @@ $ 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` +- `LOGO_SRC` _(optional)_ + - Url to the main logo + - `default`: `/static/images/Abakus_logo.png` - `COOKIE_SECRET` - **IMPORTANT** to change this to a secret value in production!! - `default`: in dev: `localsecret`, otherwise empty @@ -71,7 +74,7 @@ See `app.js` and `env.js` for the rest ```bash $ yarn build -$ LOGO_SRC=https://my-domain.tld/logo.png NODE_ENV=production GOOGLE_AUTH=base64encoding yarn start +$ ICON_SRC=https://someicon.png LOGO_SRC=https://somelogo.png NODE_ENV=production GOOGLE_AUTH=base64encoding yarn start ``` ## Using the card-readers diff --git a/app.js b/app.js index d425df40..6da229c2 100644 --- a/app.js +++ b/app.js @@ -67,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/digital/mail.js b/app/digital/mail.js index 292a7dee..0de98814 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -5,7 +5,7 @@ const path = require('path'); const handlebars = require('handlebars'); let creds = {}; -let transporter = {}; +let transporter = null; let from = ''; // Mail transporter object for production Google mail @@ -47,42 +47,56 @@ exports.mailHandler = async (action, data) => { 'utf8' ); const template = handlebars.compile(html); - const { email, username, password } = data; + let { email, username, password } = data; + username = username && username.replace(/\W/g, ''); + password = password && password.replace(/\W/g, ''); - let replacements = {}; + let replacements = { + logo: env.LOGO_SRC, + username, + password, + link: `${env.FRONTEND_URL}/auth/login?token=${username}:${password}:`, + }; switch (action) { case 'reject': replacements = { - title: 'Allerede aktivert bruker', - description: - 'Du har allerede motatt en bruker, og vi har registrert at du har klart å logge inn.', + ...replacements, + new: false, + title: 'Allerede aktivert bruker!', }; break; case 'resend': replacements = { - title: 'Velkommen til Genfors', - description: - 'Dette er din digitale bruker. Dette er den samme brukeren, men med nytt generert passord.', - username: username.replace(/\W/g, ''), - password: password.replace(/\W/g, ''), + ...replacements, + new: true, + title: 'Velkommen til Genfors!', }; break; case 'send': replacements = { - title: 'Velkommen til Genfors', - description: - 'Dette er din digital bruker til stemmesystemet VOTE. Mer info kommer på Genfors.', - username: username.replace(/\W/g, ''), - password: password.replace(/\W/g, ''), + ...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, _) { + console.log('MAIL:', action, data); // eslint-disable-line no-console + resolve('Done'); + }); + } return transporter.sendMail({ from, to: `${email}`, subject: `VOTE Login Credentials`, text: `Username: ${username}, Password: ${password}`, - html: template(replacements), + html: templatedHTML, }); }; diff --git a/app/digital/template.html b/app/digital/template.html index 5fb3f718..7c4eebce 100644 --- a/app/digital/template.html +++ b/app/digital/template.html @@ -223,10 +223,7 @@ -
- - - +
@@ -242,39 +239,33 @@ - -
- - - - -

{{title}}

-
- VOTE -
-

{{description}}

-
-

{{username}}

-

{{password}}

-
- Logg inn her -
- - - - -
- - - - -
- Abakus Linjeforening
- webkom@abakus.no
-
-
- - - + +
+ + + + +

{{title}}

+
+ 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/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/env.js b/env.js index c6e606d0..4bd06bb6 100644 --- a/env.js +++ b/env.js @@ -1,6 +1,9 @@ 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', + LOGO_SRC: + process.env.LOGO_SRC || + 'https://abakus.no/185f9aa436cf7f5da598fd7e07700efd.png', // Node environment. 'development' or 'production' NODE_ENV: process.env.NODE_ENV || 'development', // This cannot be empty when running in production @@ -15,4 +18,6 @@ module.exports = { GOOGLE_FROM_MAIL: process.env.GOOGLE_FROM_MAIL || '', // Dev mail auth ETHEREAL: process.env.ETHEREAL, + // + FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:3000', }; From 815e11fe901cbdfeaab235e78a58b1f863777e77 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 31 Jan 2021 15:22:07 +0100 Subject: [PATCH 83/98] Auto-fill username and password from token --- client/login.js | 63 +++++++++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 26 deletions(-) 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(); From 61cce08c07bb4c28ff76990022c5337684f48e90 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 31 Jan 2021 15:22:39 +0100 Subject: [PATCH 84/98] Improve mail-handler and write tests user-gen --- README.md | 4 +- app/controllers/user.js | 37 ++++++--- app/errors/index.js | 11 +++ app/models/user.js | 3 +- client/controllers/generateUserCtrl.js | 11 +-- test/api/user.test.js | 104 +++++++++++++++++++++++++ test/helpers.js | 3 +- 7 files changed, 149 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a8b47b0a..f1ee9034 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ $ yarn start - Url to the main icon on all pages - `default`: `/static/images/Abakule.jpg` - `LOGO_SRC` _(optional)_ - - Url to the main logo - - `default`: `/static/images/Abakus_logo.png` + - External email to url to the main logo + - `default`: `https://abakus.no/185f9aa436cf7f5da598fd7e07700efd.png` - `COOKIE_SECRET` - **IMPORTANT** to change this to a secret value in production!! - `default`: in dev: `localsecret`, otherwise empty diff --git a/app/controllers/user.js b/app/controllers/user.js index 2114235f..999a6b40 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -47,9 +47,8 @@ exports.create = (req, res) => { exports.generate = async (req, res) => { const { legoUser, email, ignoreExistingUser } = req.body; - if (!legoUser || !email) { - throw new errors.InvalidPayloadError('Params legoUser or email provided.'); - } + if (!legoUser) throw new errors.InvalidPayloadError('legoUser'); + if (!email) throw new errors.InvalidPayloadError('email'); // Try to fetch an entry from the register with this username const entry = await Register.findOne({ legoUser }).exec(); @@ -61,10 +60,15 @@ exports.generate = async (req, res) => { // Entry has no user this user is allready activated if (entry && !entry.user) { return mailHandler('reject', { email }) - .then(() => { - throw new errors.DuplicateLegoUserError(); - }) - .catch((err) => res.status(500).json(err)); + .then(() => + res.status(409).json({ + status: 'allready signed in', + user: legoUser, + }) + ) + .catch((err) => { + throw new errors.MailError(err); + }); } const password = crypto.randomBytes(11).toString('hex'); @@ -79,8 +83,15 @@ exports.generate = async (req, res) => { entry.email = email; return entry.save(); }) - .then(() => res.status(201).json(legoUser)) - .catch((err) => res.status(500).json(err)) + .then(() => + res.status(201).json({ + status: 'regenerated', + user: legoUser, + }) + ) + .catch((err) => { + throw new errors.MailError(err); + }) ); } @@ -99,8 +110,12 @@ exports.generate = async (req, res) => { .then((createdUser) => mailHandler('send', { email, username: createdUser.username, password }) .then(() => new Register({ legoUser, email, user }).save()) - .then(() => res.status(201).json(legoUser)) - .catch((err) => res.status(500).json(err)) + .then(() => + res.status(201).json({ status: 'generated', user: legoUser }) + ) + .catch((err) => { + throw new errors.MailError(err); + }) ) .catch(mongoose.Error.ValidationError, (err) => { throw new errors.ValidationError(err.errors); diff --git a/app/errors/index.js b/app/errors/index.js index 7b5aed82..33e01001 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -225,6 +225,17 @@ class AllreadyActiveElectionError extends Error { exports.AllreadyActiveElectionError = AllreadyActiveElectionError; +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; + exports.handleError = (res, err, status) => { const statusCode = status || err.status || 500; return res.status(statusCode).json( 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/client/controllers/generateUserCtrl.js b/client/controllers/generateUserCtrl.js index 413afea7..fa3ed076 100644 --- a/client/controllers/generateUserCtrl.js +++ b/client/controllers/generateUserCtrl.js @@ -9,24 +9,19 @@ module.exports = [ userService.generateUser(user).then( function (response) { alertService.addSuccess( - `Bruker generert/oppdatert for ${response.data}!` + `Bruker ${response.data.user} ble ${response.data.status}!` ); $scope.user = {}; $scope.pending = false; }, function (response) { $scope.pending = false; - switch (response.data.name) { - case 'DuplicateLegoUserError': + switch (response.status) { + case 409: alertService.addError( 'Denne LEGO brukern har allerede fått en bruker.' ); break; - case 'DuplicateCardError': - alertService.addError( - 'Dette kortet er allerede blitt registrert.' - ); - break; default: alertService.addError(); } diff --git a/test/api/user.test.js b/test/api/user.test.js index 5ff456fd..1c0076ec 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 = { + legoUser: 'legoUsername', + 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,102 @@ 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.legoUser); + }); + + 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 legoUser', 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 legoUser 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({ legoUser: '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.legoUser); + + // Check that the register index and the user was created + const register = await Register.findOne({ legoUser: genUserData.legoUser }); + register.legoUser.should.equal(genUserData.legoUser); + 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.legoUser); + }); + + 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.legoUser); + + // Get the register and fake that they have logged in + const register = await Register.findOne({ legoUser: genUserData.legoUser }); + 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/helpers.js b/test/helpers.js index a834055f..e6c152be 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -4,13 +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() ); From 2fc1e1db76320e4b34484c68613de4807130b644 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 31 Jan 2021 18:45:46 +0100 Subject: [PATCH 85/98] Enable normal STMP mail aswell --- README.md | 19 ++++++++++++------- app/digital/mail.js | 38 ++++++++++++++++++++------------------ env.js | 13 +++++++------ 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f1ee9034..e968cb18 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,19 @@ $ yarn start - `COOKIE_SECRET` - **IMPORTANT** to change this to a secret value in production!! - `default`: in dev: `localsecret`, otherwise empty +- `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. We also store - the `abakus_from_email` in the data object. Note that the `GOOGLE_AUTH` variable is only used when - VOTE is running in production, in development the `ETHERAL` variable can be used. -- `ETHEREAL` - - A optional variable you can set that allows emails to be routed to a test `smtp` server. This is - useful if you intend to make changes to the way emails are sent, or the way the template looks. - The variable must be on the format `user:pass`, that you can find [here](https://ethereal.email/create). + - 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 diff --git a/app/digital/mail.js b/app/digital/mail.js index 0de98814..94bd6e0e 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -6,10 +6,9 @@ const handlebars = require('handlebars'); let creds = {}; let transporter = null; -let from = ''; -// Mail transporter object for production Google mail -if (env.NODE_ENV === 'production') { +// 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({ @@ -18,27 +17,30 @@ if (env.NODE_ENV === 'production') { secure: true, auth: { type: 'OAuth2', - user: process.env.GOOGLE_FROM_MAIL, + user: process.env.FROM_MAIL, serviceClient: creds.client_id, privateKey: creds.private_key, }, }); - from = `VOTE - Abakus <${process.env.GOOGLE_FROM_MAIL}>`; + 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 object for dev Ethereal mail -if (env.NODE_ENV === 'development' && env.ETHEREAL) { - // The ethereal string should be on the format "user:pass" - const [user, pass] = env.ETHEREAL.split(':'); - transporter = nodemailer.createTransport({ - host: 'smtp.ethereal.email', - port: 587, - auth: { - user, - pass, - }, +// 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 + } }); - from = `VOTE(TEST) - Abakus <${user}>`; } exports.mailHandler = async (action, data) => { @@ -93,7 +95,7 @@ exports.mailHandler = async (action, data) => { } return transporter.sendMail({ - from, + from: `VOTE - ${process.env.FROM} <${process.env.FROM_MAIL}>`, to: `${email}`, subject: `VOTE Login Credentials`, text: `Username: ${username}, Password: ${password}`, diff --git a/env.js b/env.js index 4bd06bb6..ecbf6062 100644 --- a/env.js +++ b/env.js @@ -13,11 +13,12 @@ module.exports = { HOST: process.env.HOST || 'localhost', MONGO_URL: process.env.MONGO_URL || 'mongodb://localhost:27017/vote', REDIS_URL: process.env.REDIS_URL || 'localhost', - // Mail auth - GOOGLE_AUTH: process.env.GOOGLE_AUTH, - GOOGLE_FROM_MAIL: process.env.GOOGLE_FROM_MAIL || '', - // Dev mail auth - ETHEREAL: process.env.ETHEREAL, - // 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, }; From 17732b02160c4ee4122abbcb0412a071841cf5ab Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 31 Jan 2021 19:42:27 +0100 Subject: [PATCH 86/98] Update client/controllers/generateUserCtrl.js Co-authored-by: Ludvig --- client/controllers/generateUserCtrl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/controllers/generateUserCtrl.js b/client/controllers/generateUserCtrl.js index fa3ed076..a6018b42 100644 --- a/client/controllers/generateUserCtrl.js +++ b/client/controllers/generateUserCtrl.js @@ -19,7 +19,7 @@ module.exports = [ switch (response.status) { case 409: alertService.addError( - 'Denne LEGO brukern har allerede fått en bruker.' + 'Denne LEGO brukeren har allerede fått en bruker.' ); break; default: From ce0e1579b51518fa267201df09432981054c9f9f Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Wed, 3 Feb 2021 18:03:41 +0100 Subject: [PATCH 87/98] Spelling --- app/controllers/election.js | 2 +- app/errors/index.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/election.js b/app/controllers/election.js index 9608bda8..d96b40c4 100644 --- a/app/controllers/election.js +++ b/app/controllers/election.js @@ -99,7 +99,7 @@ function setElectionStatus(req, res, active) { exports.activate = async (req, res) => { const otherActiveElection = await Election.findOne({ active: true }); if (otherActiveElection) { - throw new errors.AllreadyActiveElectionError(); + throw new errors.AlreadyActiveElectionError(); } return setElectionStatus(req, res, true).then((election) => { const io = app.get('io'); diff --git a/app/errors/index.js b/app/errors/index.js index 33e01001..b2848fef 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -214,16 +214,16 @@ class DuplicateLegoUserError extends Error { exports.DuplicateLegoUserError = DuplicateLegoUserError; -class AllreadyActiveElectionError extends Error { +class AlreadyActiveElectionError extends Error { constructor() { super(); - this.name = 'AllreadyActiveElection'; - this.message = 'There is allready an active election'; + this.name = 'AlreadyActiveElection'; + this.message = 'There is already an active election'; this.status = 409; } } -exports.AllreadyActiveElectionError = AllreadyActiveElectionError; +exports.AlreadyActiveElectionError = AlreadyActiveElectionError; class MailError extends Error { constructor(err) { From f6cf5256a9624e34022fa91b7cf5c2ce0e66485b Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Fri, 5 Feb 2021 17:25:00 +0100 Subject: [PATCH 88/98] Remove LOGO and don't print in tests --- README.md | 5 +--- app/digital/mail.js | 9 ++++--- app/digital/template.html | 44 +---------------------------------- deployment/docker-compose.yml | 1 - env.js | 3 --- 5 files changed, 8 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index e968cb18..a9a7914b 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,6 @@ $ yarn start - `ICON_SRC` _(optional)_ - Url to the main icon on all pages - `default`: `/static/images/Abakule.jpg` -- `LOGO_SRC` _(optional)_ - - External email to url to the main logo - - `default`: `https://abakus.no/185f9aa436cf7f5da598fd7e07700efd.png` - `COOKIE_SECRET` - **IMPORTANT** to change this to a secret value in production!! - `default`: in dev: `localsecret`, otherwise empty @@ -79,7 +76,7 @@ See `app.js` and `env.js` for the rest ```bash $ yarn build -$ ICON_SRC=https://someicon.png LOGO_SRC=https://somelogo.png NODE_ENV=production GOOGLE_AUTH=base64encoding yarn start +$ ICON_SRC=https://some-domain/image.png NODE_ENV=production GOOGLE_AUTH=base64encoding yarn start ``` ## Using the card-readers diff --git a/app/digital/mail.js b/app/digital/mail.js index 94bd6e0e..29ac9df2 100644 --- a/app/digital/mail.js +++ b/app/digital/mail.js @@ -17,7 +17,7 @@ if (env.GOOGLE_AUTH) { secure: true, auth: { type: 'OAuth2', - user: process.env.FROM_MAIL, + user: env.FROM_MAIL, serviceClient: creds.client_id, privateKey: creds.private_key, }, @@ -54,7 +54,7 @@ exports.mailHandler = async (action, data) => { password = password && password.replace(/\W/g, ''); let replacements = { - logo: env.LOGO_SRC, + from: env.FROM, username, password, link: `${env.FRONTEND_URL}/auth/login?token=${username}:${password}:`, @@ -89,7 +89,10 @@ exports.mailHandler = async (action, data) => { // works even tho we dont use it. if (!transporter) { return new Promise(function (resolve, _) { - console.log('MAIL:', action, data); // eslint-disable-line no-console + // 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'); }); } diff --git a/app/digital/template.html b/app/digital/template.html index 7c4eebce..14b592a2 100644 --- a/app/digital/template.html +++ b/app/digital/template.html @@ -148,12 +148,6 @@ width: 320px !important; } - img[class="force-width-gmail"] { - display: none !important; - width: 0 !important; - height: 0 !important; - } - a[class="button-width"], a[class="button-mobile"] { width: 248px !important; @@ -196,55 +190,19 @@ width: 280px !important; padding-bottom: 40px !important; } - - td[class="info-img"], - img[class="info-img"] { - width: 278px !important; - } } - - -
-
- - - - - -
- -
- - - - -
- -
-
- -
-
-

{{title}}

diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 824a9e07..5eddbc7f 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -12,7 +12,6 @@ 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" diff --git a/env.js b/env.js index ecbf6062..dbce9c87 100644 --- a/env.js +++ b/env.js @@ -1,9 +1,6 @@ module.exports = { // URL/source to the logo on all pages ICON_SRC: process.env.ICON_SRC || '/static/images/Abakule.jpg', - LOGO_SRC: - process.env.LOGO_SRC || - 'https://abakus.no/185f9aa436cf7f5da598fd7e07700efd.png', // Node environment. 'development' or 'production' NODE_ENV: process.env.NODE_ENV || 'development', // This cannot be empty when running in production From c20f10d90e721506ee7e14f9f2913ff0235c8e36 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Fri, 5 Feb 2021 19:45:38 +0100 Subject: [PATCH 89/98] LINT --- app/views/partials/admin/editElection.pug | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/partials/admin/editElection.pug b/app/views/partials/admin/editElection.pug index 9b32a5d4..7eadccd9 100644 --- a/app/views/partials/admin/editElection.pug +++ b/app/views/partials/admin/editElection.pug @@ -6,9 +6,9 @@ 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" }} + ng-click='copyToClipboard(election.accessCode)' + ) + .cs-tooltiptext {{ copySuccess ? "Kopiert!" : "Kopier" }} .election-info.admin h3.user-status From 151656beaa655e8ac83a5eaa5243933bbb51caa2 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 6 Feb 2021 15:13:44 +0100 Subject: [PATCH 90/98] Add moderator feature to manage register --- app/controllers/register.js | 20 +++++++++++++ app/errors/index.js | 11 +++++++ app/models/register.js | 9 ++++++ app/routes/api/index.js | 2 ++ app/routes/api/register.js | 9 ++++++ .../partials/moderator/deactivateUsers.pug | 18 +++++++++++ client/controllers/deactivateUsersCtrl.js | 30 ++++++++++++++++++- client/services/index.js | 3 +- client/services/registerService.js | 12 ++++++++ client/styles/main.styl | 5 ++++ 10 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 app/controllers/register.js create mode 100644 app/routes/api/register.js create mode 100644 client/services/registerService.js diff --git a/app/controllers/register.js b/app/controllers/register.js new file mode 100644 index 00000000..d7f46088 --- /dev/null +++ b/app/controllers/register.js @@ -0,0 +1,20 @@ +const Register = require('../models/register'); +const errors = require('../errors'); + +exports.list = (req, res) => + Register.find().then((register) => res.json(register)); + +exports.delete = async (req, res) => { + const register = await Register.findOne({ + _id: req.params.registerId, + }).exec(); + 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/errors/index.js b/app/errors/index.js index b2848fef..6a95eba7 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -236,6 +236,17 @@ class MailError extends Error { 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 = 500; + } +} + +exports.NoAssociatedUserError = NoAssociatedUserError; + exports.handleError = (res, err, status) => { const statusCode = status || err.status || 500; return res.status(statusCode).json( diff --git a/app/models/register.js b/app/models/register.js index 7eccffca..0b1f0a8f 100644 --- a/app/models/register.js +++ b/app/models/register.js @@ -19,4 +19,13 @@ const registerSchema = new Schema({ }, }); +// 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/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..3d09a40f --- /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(register.delete); + +module.exports = router; diff --git a/app/views/partials/moderator/deactivateUsers.pug b/app/views/partials/moderator/deactivateUsers.pug index b8028942..05f3e2c7 100644 --- a/app/views/partials/moderator/deactivateUsers.pug +++ b/app/views/partials/moderator/deactivateUsers.pug @@ -1,3 +1,21 @@ .row .col-xs-12.col-sm-offset-3.col-sm-6.col-md-offset-4.col-md-4.text-center deactivate-users(deactivate-handler='deactivateNonAdminUsers()') Deaktiver brukere + +.center + table(style='width:100%') + thead + tr + th Navn + th Email + th Status + tbody + tr(ng-repeat='register in registers') + th {{ register.legoUser }} + th {{ register.email }} + th {{ register.user ? "Ikke Aktivert" : "Aktivert" }} + th + button.btn.btn-default( + ng-disabled='!register.user', + ng-click='deleteRegister(register._id)' + ) Slett diff --git a/client/controllers/deactivateUsersCtrl.js b/client/controllers/deactivateUsersCtrl.js index 915fa9e5..18af7236 100644 --- a/client/controllers/deactivateUsersCtrl.js +++ b/client/controllers/deactivateUsersCtrl.js @@ -4,8 +4,22 @@ module.exports = [ '$scope', '$route', 'userService', + 'registerService', 'alertService', - function ($scope, $route, userService, alertService) { + function ($scope, $route, userService, registerService, alertService) { + $scope.registers = []; + function getRegisterEntries() { + registerService.getRegisterEntries().then( + function (response) { + $scope.registers = response.data; + }, + function (response) { + alertService.addError(response.message); + } + ); + } + getRegisterEntries(); + $scope.deactivateNonAdminUsers = function () { userService.deactivateNonAdminUsers().then( function (response) { @@ -19,5 +33,19 @@ module.exports = [ $route.reload(); }; + + $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/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/styles/main.styl b/client/styles/main.styl index bbef2f41..443fb51b 100644 --- a/client/styles/main.styl +++ b/client/styles/main.styl @@ -49,6 +49,11 @@ header &:focus text-decoration none +thead + font-style bold + border-bottom 1px solid black + + .header text-align center font-size 60px From 4ae3e9f639e77bac096b0b298a75e74c90a04911 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 6 Feb 2021 17:53:19 +0100 Subject: [PATCH 91/98] Add tests, fix permissions, better errors --- app/controllers/register.js | 13 +++++- app/errors/index.js | 2 +- app/routes/api/register.js | 2 +- test/api/register.test.js | 92 +++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 test/api/register.test.js diff --git a/app/controllers/register.js b/app/controllers/register.js index d7f46088..3dfb3343 100644 --- a/app/controllers/register.js +++ b/app/controllers/register.js @@ -1,16 +1,27 @@ 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, - }).exec(); + }); + + 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.', diff --git a/app/errors/index.js b/app/errors/index.js index 6a95eba7..97e85d53 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -241,7 +241,7 @@ class NoAssociatedUserError extends Error { super(); this.name = 'NoAssociatedUserError'; this.message = "Can't delete a register with no associated user"; - this.status = 500; + this.status = 400; } } diff --git a/app/routes/api/register.js b/app/routes/api/register.js index 3d09a40f..cd9dc4ef 100644 --- a/app/routes/api/register.js +++ b/app/routes/api/register.js @@ -4,6 +4,6 @@ const ensureModerator = require('../helpers').ensureModerator; router.route('/').get(ensureModerator, register.list); -router.route('/:registerId').delete(register.delete); +router.route('/:registerId').delete(ensureModerator, register.delete); module.exports = router; diff --git a/test/api/register.test.js b/test/api/register.test.js new file mode 100644 index 00000000..16b41a50 --- /dev/null +++ b/test/api/register.test.js @@ -0,0 +1,92 @@ +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({ legoUser: 'username', email: 'email@domain.com' }); + }); + + after(() => { + passportStub.logout(); + passportStub.uninstall(); + }); + + it('should be possible for a moderator to 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].legoUser.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."); + }); +}); From 254502cb79fa31c8accda050b271ce8a2232c577 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sat, 6 Feb 2021 19:12:35 +0100 Subject: [PATCH 92/98] Move view, add search --- app/views/moderatorIndex.pug | 2 ++ .../partials/moderator/deactivateUsers.pug | 22 +++--------- .../partials/moderator/manageRegister.pug | 31 +++++++++++++++++ client/appRoutes.js | 5 +++ client/controllers/deactivateUsersCtrl.js | 30 +--------------- client/controllers/index.js | 3 +- client/controllers/manageRegisterCtrl.js | 34 +++++++++++++++++++ client/styles/main.styl | 5 --- 8 files changed, 79 insertions(+), 53 deletions(-) create mode 100644 app/views/partials/moderator/manageRegister.pug create mode 100644 client/controllers/manageRegisterCtrl.js diff --git a/app/views/moderatorIndex.pug b/app/views/moderatorIndex.pug index 573b29c1..02cc1658 100644 --- a/app/views/moderatorIndex.pug +++ b/app/views/moderatorIndex.pug @@ -17,6 +17,8 @@ block navbar 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/moderator/deactivateUsers.pug b/app/views/partials/moderator/deactivateUsers.pug index 05f3e2c7..047df253 100644 --- a/app/views/partials/moderator/deactivateUsers.pug +++ b/app/views/partials/moderator/deactivateUsers.pug @@ -1,21 +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 - -.center - table(style='width:100%') - thead - tr - th Navn - th Email - th Status - tbody - tr(ng-repeat='register in registers') - th {{ register.legoUser }} - th {{ register.email }} - th {{ register.user ? "Ikke Aktivert" : "Aktivert" }} - th - button.btn.btn-default( - ng-disabled='!register.user', - ng-click='deleteRegister(register._id)' - ) Slett diff --git a/app/views/partials/moderator/manageRegister.pug b/app/views/partials/moderator/manageRegister.pug new file mode 100644 index 00000000..f815ab63 --- /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 + 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 Brukernavn + 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.legoUser }} + 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/client/appRoutes.js b/client/appRoutes.js index 733217a3..488dda89 100644 --- a/client/appRoutes.js +++ b/client/appRoutes.js @@ -75,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/deactivateUsersCtrl.js b/client/controllers/deactivateUsersCtrl.js index 18af7236..915fa9e5 100644 --- a/client/controllers/deactivateUsersCtrl.js +++ b/client/controllers/deactivateUsersCtrl.js @@ -4,22 +4,8 @@ module.exports = [ '$scope', '$route', 'userService', - 'registerService', 'alertService', - function ($scope, $route, userService, registerService, alertService) { - $scope.registers = []; - function getRegisterEntries() { - registerService.getRegisterEntries().then( - function (response) { - $scope.registers = response.data; - }, - function (response) { - alertService.addError(response.message); - } - ); - } - getRegisterEntries(); - + function ($scope, $route, userService, alertService) { $scope.deactivateNonAdminUsers = function () { userService.deactivateNonAdminUsers().then( function (response) { @@ -33,19 +19,5 @@ module.exports = [ $route.reload(); }; - - $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/controllers/index.js b/client/controllers/index.js index bcd1e7ab..973b3778 100644 --- a/client/controllers/index.js +++ b/client/controllers/index.js @@ -12,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/styles/main.styl b/client/styles/main.styl index 443fb51b..bbef2f41 100644 --- a/client/styles/main.styl +++ b/client/styles/main.styl @@ -49,11 +49,6 @@ header &:focus text-decoration none -thead - font-style bold - border-bottom 1px solid black - - .header text-align center font-size 60px From f3ed24887e78599448aae8d1a6de26016e27f01d Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 7 Feb 2021 15:44:40 +0100 Subject: [PATCH 93/98] Update test/api/register.test.js Co-authored-by: Ludvig --- test/api/register.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/register.test.js b/test/api/register.test.js index 16b41a50..65fc23a1 100644 --- a/test/api/register.test.js +++ b/test/api/register.test.js @@ -27,7 +27,7 @@ describe('Register API', () => { passportStub.uninstall(); }); - it('should be possible for a moderator to a list of registers', async function () { + 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') From af62f910b4f2d5f573d9854ad2bcdab6a2eedf7b Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 7 Feb 2021 15:48:35 +0100 Subject: [PATCH 94/98] Add test for no associated user --- test/api/register.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/api/register.test.js b/test/api/register.test.js index 65fc23a1..0cf7d8e3 100644 --- a/test/api/register.test.js +++ b/test/api/register.test.js @@ -89,4 +89,20 @@ describe('Register API', () => { 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" + ); + }); }); From 31c1a5ca944d180d208dcd9d6bc1d97d9e1c2e69 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Fri, 5 Feb 2021 16:56:05 +0100 Subject: [PATCH 95/98] Fix and add E2E tests for new STV voting --- features/election.feature | 28 ++++++- features/step_definitions/electionSteps.js | 96 ++++++++++++++++++++-- features/step_definitions/webSteps.js | 2 +- features/support/hooks.js | 42 ++++++---- 4 files changed, 138 insertions(+), 30 deletions(-) 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/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..d1480373 100644 --- a/features/step_definitions/webSteps.js +++ b/features/step_definitions/webSteps.js @@ -80,7 +80,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); }); 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) => { From 0892406378baa6f79540bccca23361672b7c8937 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Sun, 7 Feb 2021 18:42:56 +0100 Subject: [PATCH 96/98] Fix E2E tests for admin view with STV --- app/views/partials/admin/editElection.pug | 4 +-- features/admin.feature | 4 ++- features/step_definitions/adminSteps.js | 42 +++++++++++++++++++---- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/app/views/partials/admin/editElection.pug b/app/views/partials/admin/editElection.pug index 7eadccd9..d8429f69 100644 --- a/app/views/partials/admin/editElection.pug +++ b/app/views/partials/admin/editElection.pug @@ -86,11 +86,11 @@ span.cs-tooltiptext Antall stemmer span / span.cs-tooltip {{ election.seats + 1 }} - span.cs-tooltiptext Antall stemmer + 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') + 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 diff --git a/features/admin.feature b/features/admin.feature index 406db96f..f2744adb 100644 --- a/features/admin.feature +++ b/features/admin.feature @@ -19,8 +19,10 @@ Feature: Admin 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 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/step_definitions/adminSteps.js b/features/step_definitions/adminSteps.js index 4541bde2..d8e2807f 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) => { From 34e741c834be2d4f8148ebf26fed856e5522dfb1 Mon Sep 17 00:00:00 2001 From: Peder Smith Date: Sun, 7 Feb 2021 18:45:35 +0100 Subject: [PATCH 97/98] Rename legoUser to identifier so it's not linked to LEGO --- app/controllers/user.js | 16 ++++++------ app/errors/index.js | 8 +++--- app/models/register.js | 2 +- app/views/partials/moderator/generateUser.pug | 8 +++--- .../partials/moderator/manageRegister.pug | 6 ++--- client/controllers/generateUserCtrl.js | 2 +- deployment/sync/main.go | 8 +++--- test/api/register.test.js | 4 +-- test/api/user.test.js | 26 +++++++++++-------- 9 files changed, 42 insertions(+), 38 deletions(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index 999a6b40..f152ca50 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -45,16 +45,16 @@ exports.create = (req, res) => { }; exports.generate = async (req, res) => { - const { legoUser, email, ignoreExistingUser } = req.body; + const { identifier, email, ignoreExistingUser } = req.body; - if (!legoUser) throw new errors.InvalidPayloadError('legoUser'); + 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({ legoUser }).exec(); + const entry = await Register.findOne({ identifier }).exec(); if (entry && ignoreExistingUser) { - return res.status(409).json(legoUser); + return res.status(409).json(identifier); } // Entry has no user this user is allready activated @@ -63,7 +63,7 @@ exports.generate = async (req, res) => { .then(() => res.status(409).json({ status: 'allready signed in', - user: legoUser, + user: identifier, }) ) .catch((err) => { @@ -86,7 +86,7 @@ exports.generate = async (req, res) => { .then(() => res.status(201).json({ status: 'regenerated', - user: legoUser, + user: identifier, }) ) .catch((err) => { @@ -109,9 +109,9 @@ exports.generate = async (req, res) => { return User.register(user, password) .then((createdUser) => mailHandler('send', { email, username: createdUser.username, password }) - .then(() => new Register({ legoUser, email, user }).save()) + .then(() => new Register({ identifier, email, user }).save()) .then(() => - res.status(201).json({ status: 'generated', user: legoUser }) + res.status(201).json({ status: 'generated', user: identifier }) ) .catch((err) => { throw new errors.MailError(err); diff --git a/app/errors/index.js b/app/errors/index.js index 97e85d53..75d2ea87 100644 --- a/app/errors/index.js +++ b/app/errors/index.js @@ -203,16 +203,16 @@ class DuplicateUsernameError extends Error { exports.DuplicateUsernameError = DuplicateUsernameError; -class DuplicateLegoUserError extends Error { +class DuplicateIdentifierError extends Error { constructor() { super(); - this.name = 'DuplicateLegoUserError'; - this.message = 'This LEGO user has allready gotten a user.'; + this.name = 'DuplicateIdentifierError'; + this.message = 'This identifier has allready gotten a user.'; this.status = 409; } } -exports.DuplicateLegoUserError = DuplicateLegoUserError; +exports.DuplicateIdentifierError = DuplicateIdentifierError; class AlreadyActiveElectionError extends Error { constructor() { diff --git a/app/models/register.js b/app/models/register.js index 0b1f0a8f..de258b4f 100644 --- a/app/models/register.js +++ b/app/models/register.js @@ -3,7 +3,7 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; const registerSchema = new Schema({ - legoUser: { + identifier: { type: String, required: true, unique: true, diff --git a/app/views/partials/moderator/generateUser.pug b/app/views/partials/moderator/generateUser.pug index a76ecf31..743937e2 100644 --- a/app/views/partials/moderator/generateUser.pug +++ b/app/views/partials/moderator/generateUser.pug @@ -2,12 +2,12 @@ .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 LEGO User + label Identifikator input.form-control( type='text', - name='legoUser', - placeholder='Skriv inn LEGO User', - ng-model='user.legoUser' + name='identifier', + placeholder='Skriv inn identifikator', + ng-model='user.identifier' ) .form-group diff --git a/app/views/partials/moderator/manageRegister.pug b/app/views/partials/moderator/manageRegister.pug index f815ab63..cceb8ad5 100644 --- a/app/views/partials/moderator/manageRegister.pug +++ b/app/views/partials/moderator/manageRegister.pug @@ -7,7 +7,7 @@ | laget med "Registrer bruker" eller "QR" vil ikke vises under. .center(style='margin-top: 50px') hr - .usage-flex + .usage-flex(style='margin: 0') h4(style='text-align:right') Totalt {{ registers.length }} genererte brukere label Search: input(ng-model='searchText') @@ -15,12 +15,12 @@ table(style='width:100%; margin-top: 30px') thead(style='border-bottom:2px solid black') tr - th Brukernavn + 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.legoUser }} + th {{ register.identifier }} th {{ register.email }} th(style='text-align:center') {{ register.user ? "Nei" : "Ja" }} th(style='text-align:right') diff --git a/client/controllers/generateUserCtrl.js b/client/controllers/generateUserCtrl.js index a6018b42..aec6efc9 100644 --- a/client/controllers/generateUserCtrl.js +++ b/client/controllers/generateUserCtrl.js @@ -19,7 +19,7 @@ module.exports = [ switch (response.status) { case 409: alertService.addError( - 'Denne LEGO brukeren har allerede fått en bruker.' + 'Denne idenfikatoren har allerede fått en bruker.' ); break; default: 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/test/api/register.test.js b/test/api/register.test.js index 0cf7d8e3..d66a3660 100644 --- a/test/api/register.test.js +++ b/test/api/register.test.js @@ -19,7 +19,7 @@ describe('Register API', () => { passportStub.login(this.moderatorUser.username); await request(app) .post('/api/user/generate') - .send({ legoUser: 'username', email: 'email@domain.com' }); + .send({ identifier: 'username', email: 'email@domain.com' }); }); after(() => { @@ -34,7 +34,7 @@ describe('Register API', () => { .expect(200) .expect('Content-Type', /json/); body.length.should.equal(1); - body[0].legoUser.should.equal('username'); + body[0].identifier.should.equal('username'); body[0].email.should.equal('email@domain.com'); }); diff --git a/test/api/user.test.js b/test/api/user.test.js index 1c0076ec..ccccf83f 100644 --- a/test/api/user.test.js +++ b/test/api/user.test.js @@ -42,7 +42,7 @@ describe('User API', () => { }; const genUserData = { - legoUser: 'legoUsername', + identifier: 'identifiername', email: 'test@user.com', }; @@ -376,7 +376,7 @@ describe('User API', () => { .expect(201) .expect('Content-Type', /json/); body.status.should.equal('generated'); - body.user.should.equal(genUserData.legoUser); + body.user.should.equal(genUserData.identifier); }); it('should be not be possible to generate a user for a user', async function () { @@ -390,7 +390,7 @@ describe('User API', () => { error.status.should.equal(403); }); - it('should get an error when generating user with no legoUser', async function () { + 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') @@ -399,14 +399,14 @@ describe('User API', () => { .expect('Content-Type', /json/); error.name.should.equal('InvalidPayloadError'); error.status.should.equal(400); - error.message.should.equal('Missing property legoUser from payload.'); + 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({ legoUser: 'correct', password: 'wrong' }) + .send({ identifier: 'correct', password: 'wrong' }) .expect(400) .expect('Content-Type', /json/); error.name.should.equal('InvalidPayloadError'); @@ -422,11 +422,13 @@ describe('User API', () => { .expect(201) .expect('Content-Type', /json/); bodyOne.status.should.equal('generated'); - bodyOne.user.should.equal(genUserData.legoUser); + bodyOne.user.should.equal(genUserData.identifier); // Check that the register index and the user was created - const register = await Register.findOne({ legoUser: genUserData.legoUser }); - register.legoUser.should.equal(genUserData.legoUser); + 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); @@ -439,7 +441,7 @@ describe('User API', () => { .expect(201) .expect('Content-Type', /json/); bodyTwo.status.should.equal('regenerated'); - bodyTwo.user.should.equal(genUserData.legoUser); + bodyTwo.user.should.equal(genUserData.identifier); }); it('should not be possible to generate the same user twice if they are active', async function () { @@ -450,10 +452,12 @@ describe('User API', () => { .expect(201) .expect('Content-Type', /json/); bodyOne.status.should.equal('generated'); - bodyOne.user.should.equal(genUserData.legoUser); + bodyOne.user.should.equal(genUserData.identifier); // Get the register and fake that they have logged in - const register = await Register.findOne({ legoUser: genUserData.legoUser }); + const register = await Register.findOne({ + identifier: genUserData.identifier, + }); register.user = null; await register.save(); From 8b62a00a29955f2767acf167393ff4c8b7b11219 Mon Sep 17 00:00:00 2001 From: Ludvig Hozman Date: Tue, 9 Feb 2021 11:36:04 +0100 Subject: [PATCH 98/98] Add some more admin tests --- app/views/partials/admin/editElection.pug | 9 +++++--- features/admin.feature | 25 ++++++++++++++++++++++- features/step_definitions/adminSteps.js | 2 +- features/step_definitions/webSteps.js | 13 ++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/app/views/partials/admin/editElection.pug b/app/views/partials/admin/editElection.pug index d8429f69..087bfb58 100644 --- a/app/views/partials/admin/editElection.pug +++ b/app/views/partials/admin/editElection.pug @@ -72,13 +72,16 @@ tbody tr th.th-left Stemmer - th.th-right = {{ election.voteCount }} + th.th-right = + span(ng-bind='election.voteCount') {{ election.voteCount }} tr th.th-left ∟ Hvorav blanke stemmer - th.th-right = {{ election.blankVoteCount }} + th.th-right = + span(ng-bind='election.blankVoteCount') {{ election.blankVoteCount }} tr th.th-left Plasser - th.th-right = {{ election.seats }} + th.th-right = + span(ng-bind='election.seats') {{ election.seats }} tr th.th-left Terskel th.th-right ⌊ diff --git a/features/admin.feature b/features/admin.feature index f2744adb..ce1d8b23 100644 --- a/features/admin.feature +++ b/features/admin.feature @@ -8,12 +8,32 @@ 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 @@ -21,6 +41,9 @@ Feature: Admin And I am on the edit election page 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 diff --git a/features/step_definitions/adminSteps.js b/features/step_definitions/adminSteps.js index d8e2807f..87348382 100644 --- a/features/step_definitions/adminSteps.js +++ b/features/step_definitions/adminSteps.js @@ -129,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/webSteps.js b/features/step_definitions/webSteps.js index d1480373..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) ); @@ -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); + } + ); };
- VOTE + {{from}} - VOTE