Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.02% covered (danger)
36.02%
85 / 236
12.00% covered (danger)
12.00%
3 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Election
36.02% covered (danger)
36.02%
85 / 236
12.00% covered (danger)
12.00%
3 / 25
1508.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 getMessageNames
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getElection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getChildren
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStartDate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEndDate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isStarted
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 isFinished
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getVotesCount
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getBallot
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getQualifiedStatus
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
30
 isAdmin
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 hasVoted
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 allowChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQuestions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getAuth
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCrypt
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getTallyType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 dumpVotesToCallback
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 getConfXml
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
12
 getPropertyDumpExclusion
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 tally
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getTallyFromDb
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getTallyResultTimeFromDb
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Entities;
4
5use InvalidArgumentException;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\Extension\SecurePoll\Ballots\Ballot;
8use MediaWiki\Extension\SecurePoll\Context;
9use MediaWiki\Extension\SecurePoll\Crypt\Crypt;
10use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
11use MediaWiki\Extension\SecurePoll\Talliers\ElectionTallier;
12use MediaWiki\Extension\SecurePoll\User\Auth;
13use MediaWiki\Extension\SecurePoll\User\Voter;
14use MediaWiki\Permissions\Authority;
15use MediaWiki\Status\Status;
16use MediaWiki\Xml\Xml;
17use Wikimedia\Rdbms\IDatabase;
18
19/**
20 * Class representing an *election*. The term is intended to include straw polls,
21 * surveys, etc. An election has one or more *questions* which voters answer.
22 * The *voters* submit their *votes*, which are later tallied to provide a result.
23 * An election runs only once and produces a single result.
24 *
25 * Each election has its own independent set of voters. Voters are created
26 * when the underlying user attempts to vote. A voter may vote more than once,
27 * unless the election disallows this, but only one of their votes is counted.
28 *
29 * Elections have a list of key/value pairs called properties, which are defined
30 * and used by various modules in order to configure the election. The properties,
31 * in order of the module that defines them, are as follows:
32 *
33 *      Election
34 *          min-edits
35 *              Minimum number of edits needed to be qualified
36 *          max-registration
37 *              Latest acceptable registration date
38 *          not-sitewide-blocked
39 *              True if voters need to not have a sitewide block
40 *          not-partial-blocked
41 *              True if voters need to not have a partial block
42 *          not-bot
43 *              True if voters need to not have the bot permission
44 *          need-group
45 *              The name of an MW group voters need to be in
46 *          need-list
47 *              The name of a SecurePoll list voters need to be in
48 *          need-central-list
49 *              The name of a list in the CentralAuth database which is linked
50 *              to globaluser.gu_id
51 *          include-list
52 *              The name of a SecurePoll list of voters who can vote regardless of the above
53 *          exclude-list
54 *              The name of a SecurePoll list of voters who may not vote regardless of the above
55 *          admins
56 *              A list of admin names, pipe separated
57 *          disallow-change
58 *              True if a voter is not allowed to change their vote
59 *          encrypt-type
60 *              The encryption module name
61 *          not-centrally-blocked
62 *              True if voters need to not be blocked on more than X projects
63 *          central-block-threshold
64 *              Number of blocks across projects that disqualify a user from voting.
65 *          voter-privacy
66 *              True to disable transparency features (public voter list and
67 *              public encrypted record dump) in favour of preserving voter
68 *              privacy.
69 *
70 *      See the other module for documentation of the following.
71 *
72 *      RemoteMWAuth
73 *          remote-mw-script-path
74 *
75 *      Ballot
76 *          shuffle-questions
77 *          shuffle-options
78 *
79 *      OpenSslCrypt
80 *          openssl-encrypt-key
81 *          openssl-sign-key
82 *          openssl-decrypt-key
83 *          openssl-verify-key
84 *
85 *      VotePage
86 *          jump-url
87 *          jump-id
88 *          return-url
89 */
90class Election extends Entity {
91    /** @var Question[]|null */
92    public $questions;
93    /** @var Auth|null */
94    public $auth;
95    /** @var Ballot|null */
96    public $ballot;
97    /** @var string */
98    public $id;
99    /** @var string */
100    public $title;
101    /** @var string */
102    public $ballotType;
103    /** @var string */
104    public $tallyType;
105    /** @var string */
106    public $primaryLang;
107    /** @var string */
108    public $startDate;
109    /** @var string */
110    public $endDate;
111    /** @var string */
112    public $authType;
113    /** @var int */
114    public $owner = 0;
115
116    /**
117     * Constructor.
118     *
119     * Do not use this constructor directly, instead use
120     * Context::getElection().
121     * @param Context $context
122     * @param array $info
123     */
124    public function __construct( $context, $info ) {
125        parent::__construct( $context, 'election', $info );
126        $this->id = $info['id'];
127        $this->title = $info['title'];
128        $this->ballotType = $info['ballot'];
129        $this->tallyType = $info['tally'];
130        $this->primaryLang = $info['primaryLang'];
131        $this->startDate = $info['startDate'];
132        $this->endDate = $info['endDate'];
133        $this->authType = $info['auth'];
134        if ( isset( $info['owner'] ) ) {
135            $this->owner = $info['owner'];
136        }
137    }
138
139    /**
140     * Get a list of localisable message names. See Entity.
141     * @return array
142     */
143    public function getMessageNames() {
144        return [
145            'title',
146            'intro',
147            'jump-text',
148            'return-text',
149            'unqualified-error',
150            'comment-prompt'
151        ];
152    }
153
154    /**
155     * Get the election's parent election... hmm...
156     * @return Election
157     */
158    public function getElection() {
159        return $this;
160    }
161
162    /**
163     * Get a list of child entity objects. See Entity.
164     * @return array
165     */
166    public function getChildren() {
167        return $this->getQuestions();
168    }
169
170    /**
171     * Get the start date in MW internal form.
172     * @return string
173     */
174    public function getStartDate() {
175        return $this->startDate;
176    }
177
178    /**
179     * Get the end date in MW internal form.
180     * @return string
181     */
182    public function getEndDate() {
183        return $this->endDate;
184    }
185
186    /**
187     * Returns true if the election has started.
188     * @param string|bool $ts The reference timestamp, or false for now.
189     * @return bool
190     */
191    public function isStarted( $ts = false ) {
192        if ( $ts === false ) {
193            $ts = wfTimestampNow();
194        }
195
196        return !$this->startDate || $ts >= $this->startDate;
197    }
198
199    /**
200     * Returns true if the election has finished.
201     * @param string|bool $ts The reference timestamp, or false for now.
202     * @return bool
203     */
204    public function isFinished( $ts = false ) {
205        if ( $ts === false ) {
206            $ts = wfTimestampNow();
207        }
208
209        return $this->endDate && $ts >= $this->endDate;
210    }
211
212    /**
213     * Returns number of votes from an election.
214     * @return string
215     */
216    public function getVotesCount() {
217        $dbr = $this->context->getDB( DB_REPLICA );
218
219        return $dbr->newSelectQueryBuilder()
220            ->select( 'COUNT(*)' )
221            ->from( 'securepoll_votes' )
222            ->where( [
223                'vote_election' => $this->getId(),
224                'vote_current' => 1,
225                'vote_struck' => 0,
226            ] )
227            ->caller( __METHOD__ )
228            ->fetchField();
229    }
230
231    /**
232     * Get the ballot object for this election.
233     * @return Ballot
234     */
235    public function getBallot() {
236        if ( !$this->ballot ) {
237            $this->ballot = $this->context->newBallot( $this->ballotType, $this );
238        }
239
240        return $this->ballot;
241    }
242
243    /**
244     * Determine whether a voter would be qualified to vote in this election,
245     * based on the given associative array of parameters.
246     * @param array $params Associative array
247     * @return Status
248     */
249    public function getQualifiedStatus( $params ) {
250        $lang = RequestContext::getMain()->getLanguage();
251        $props = $params['properties'];
252        $status = Status::newGood();
253
254        $lists = $props['lists'] ?? [];
255        $centralLists = $props['central-lists'] ?? [];
256        $includeList = $this->getProperty( 'include-list' );
257        $excludeList = $this->getProperty( 'exclude-list' );
258
259        $includeUserGroups = explode( '|', $this->getProperty( 'allow-usergroups', '' ) );
260        $inAllowedUserGroups = array_intersect( $includeUserGroups, $props['groups'] );
261
262        if ( $excludeList && in_array( $excludeList, $lists ) ) {
263            $status->fatal( 'securepoll-in-exclude-list' );
264        } elseif ( ( $includeList && in_array( $includeList, $lists ) ) ||
265            $inAllowedUserGroups ) {
266            // Good
267        } else {
268            // Edits
269            $minEdits = $this->getProperty( 'min-edits' );
270            $edits = $props['edit-count'] ?? 0;
271            if ( $minEdits && $edits < $minEdits ) {
272                $status->fatal(
273                    'securepoll-too-few-edits',
274                    $lang->formatNum( $minEdits ),
275                    $lang->formatNum( $edits )
276                );
277            }
278
279            // Registration date
280            $maxDate = $this->getProperty( 'max-registration' );
281            $date = $props['registration'] ?? 0;
282            if ( $maxDate && $date > $maxDate ) {
283                $status->fatal(
284                    'securepoll-too-new',
285                    $lang->date( $maxDate ),
286                    $lang->date( $date ),
287                    $lang->time( $maxDate ),
288                    $lang->time( $date )
289                );
290            }
291
292            // Blocked
293            $notAllowedSitewideBlocked = $this->getProperty( 'not-sitewide-blocked' );
294            $notPartialBlocked = $this->getProperty( 'not-partial-blocked' );
295            $isBlocked = !empty( $props['blocked'] );
296            $isSitewideBlocked = $props['isSitewideBlocked'] ?? false;
297            if ( $notAllowedSitewideBlocked && $isBlocked && $isSitewideBlocked ) {
298                $status->fatal( 'securepoll-blocked' );
299            } elseif ( $notPartialBlocked && $isBlocked && !$isSitewideBlocked ) {
300                $status->fatal( 'securepoll-blocked-partial' );
301            }
302
303            // Centrally blocked on more than X projects
304            $notCentrallyBlocked = $this->getProperty( 'not-centrally-blocked' );
305            if ( $notPartialBlocked ) {
306                $centralBlockCount = $props['central-block-count'] ?? 0;
307            } else {
308                $centralBlockCount = $props['central-sitewide-block-count'] ?? 0;
309            }
310            $centralBlockThreshold = $this->getProperty( 'central-block-threshold', 1 );
311            if ( $notCentrallyBlocked && $centralBlockCount >= $centralBlockThreshold ) {
312                $status->fatal(
313                    'securepoll-blocked-centrally',
314                    $lang->formatNum( $centralBlockThreshold )
315                );
316            }
317
318            // Bot
319            $notBot = $this->getProperty( 'not-bot' );
320            $isBot = !empty( $props['bot'] );
321            if ( $notBot && $isBot ) {
322                $status->fatal( 'securepoll-bot' );
323            }
324
325            // Groups
326            $needGroup = $this->getProperty( 'need-group' );
327            $groups = $props['groups'] ?? [];
328            if ( $needGroup && !in_array( $needGroup, $groups ) ) {
329                $status->fatal( 'securepoll-not-in-group', $needGroup );
330            }
331
332            // Lists
333            $needList = $this->getProperty( 'need-list' );
334            if ( $needList && !in_array( $needList, $lists ) ) {
335                $status->fatal( 'securepoll-not-in-list' );
336            }
337
338            $needCentralList = $this->getProperty( 'need-central-list' );
339            if ( $needCentralList && !in_array( $needCentralList, $centralLists ) ) {
340                $status->fatal( 'securepoll-not-in-list' );
341            }
342        }
343
344        // Get custom error message and add it to the status's error messages
345        if ( !$status->isOK() ) {
346            $errorMsgText = $this->getMessage( 'unqualified-error' );
347            if ( $errorMsgText !== '[unqualified-error]' && $errorMsgText !== '' ) {
348                // We create the message as a separate step so that possible wikitext in
349                // $errorMsgText gets parsed.
350                $errorMsg = wfMessage( 'securepoll-custom-unqualified', $errorMsgText );
351                $status->error( $errorMsg );
352            }
353        }
354
355        return $status;
356    }
357
358    /**
359     * Returns true if the user is an admin of the current election.
360     * @param Authority $authority
361     * @return bool
362     */
363    public function isAdmin( Authority $authority ) {
364        $admins = array_map( 'trim', explode( '|', $this->getProperty( 'admins' ) ) );
365
366        return in_array( $authority->getUser()->getName(), $admins )
367            && $authority->isAllowed( 'securepoll-edit-poll' );
368    }
369
370    /**
371     * Returns true if the voter has voted already.
372     * @param Voter $voter
373     * @return bool
374     */
375    public function hasVoted( $voter ) {
376        $db = $this->context->getDB();
377        $row = $db->newSelectQueryBuilder()
378            ->select( '1' )
379            ->from( 'securepoll_votes' )
380            ->where( [
381                'vote_election' => $this->getId(),
382                'vote_voter' => $voter->getId(),
383            ] )
384            ->caller( __METHOD__ )
385            ->fetchRow();
386
387        return $row !== false;
388    }
389
390    /**
391     * Returns true if the election allows voters to change their vote after it
392     * is initially cast.
393     * @return bool
394     */
395    public function allowChange() {
396        return !$this->getProperty( 'disallow-change' );
397    }
398
399    /**
400     * Get the questions in this election
401     * @return Question[]
402     */
403    public function getQuestions() {
404        if ( $this->questions === null ) {
405            $info = $this->context->getStore()->getQuestionInfo( $this->getId() );
406            $this->questions = [];
407            foreach ( $info as $questionInfo ) {
408                $this->questions[] = $this->context->newQuestion( $questionInfo );
409            }
410        }
411
412        return $this->questions;
413    }
414
415    /**
416     * Get the authorization object.
417     * @return Auth
418     */
419    public function getAuth() {
420        if ( !$this->auth ) {
421            $this->auth = $this->context->newAuth( $this->authType );
422        }
423
424        return $this->auth;
425    }
426
427    /**
428     * Get the primary language for this election. This language will be used as
429     * a default in the relevant places.
430     * @return string
431     */
432    public function getLanguage() {
433        return $this->primaryLang;
434    }
435
436    /**
437     * Get the cryptography module for this election, or false if none is
438     * defined.
439     * @return Crypt|false
440     * @throws InvalidDataException
441     */
442    public function getCrypt() {
443        $type = $this->getProperty( 'encrypt-type', 'none' );
444        try {
445            return $this->context->newCrypt( $type, $this );
446        } catch ( InvalidArgumentException $e ) {
447            throw new InvalidDataException( 'Invalid encryption type' );
448        }
449    }
450
451    /**
452     * Get the tally type
453     * @return string
454     */
455    public function getTallyType() {
456        return $this->tallyType;
457    }
458
459    /**
460     * Call a callback function for each valid vote record, in random order.
461     * @param callable $callback
462     * @return Status
463     */
464    public function dumpVotesToCallback( $callback ) {
465        $random = $this->context->getRandom();
466        $status = $random->open();
467        if ( !$status->isOK() ) {
468            return $status;
469        }
470        $db = $this->context->getDB();
471        $res = $db->newSelectQueryBuilder()
472            ->select( '*' )
473            ->from( 'securepoll_votes' )
474            ->where( [
475                'vote_election' => $this->getId(),
476                'vote_current' => 1,
477                'vote_struck' => 0
478            ] )
479            ->caller( __METHOD__ )
480            ->fetchResultSet();
481        if ( $res->numRows() ) {
482            $order = $random->shuffle( range( 0, $res->numRows() - 1 ) );
483            foreach ( $order as $i ) {
484                $res->seek( $i );
485                $callback( $this, $res->fetchObject() );
486            }
487        }
488        $random->close();
489
490        return Status::newGood();
491    }
492
493    /**
494     * Get an XML snippet describing the configuration of this object
495     * @param array $params
496     * @return string
497     */
498    public function getConfXml( $params = [] ) {
499        $s = "<configuration>\n" . Xml::element( 'title', [], $this->title ) . "\n" . Xml::element(
500                'ballot',
501                [],
502                $this->ballotType
503            ) . "\n" . Xml::element( 'tally', [], $this->tallyType ) . "\n" . Xml::element(
504                'primaryLang',
505                [],
506                $this->primaryLang
507            ) . "\n" . Xml::element(
508                'startDate',
509                [],
510                wfTimestamp( TS_ISO_8601, $this->startDate )
511            ) . "\n" . Xml::element(
512                'endDate',
513                [],
514                wfTimestamp( TS_ISO_8601, $this->endDate )
515            ) . "\n" . $this->getConfXmlEntityStuff( $params );
516
517        // If we're making a jump dump, we need to add some extra properties, and
518        // override the auth type
519        if ( !empty( $params['jump'] ) ) {
520            $s .= Xml::element( 'auth', [], 'local' ) . "\n" . Xml::element(
521                    'property',
522                    [ 'name' => 'jump-url' ],
523                    $this->context->getSpecialTitle()->getCanonicalURL()
524                ) . "\n" . Xml::element(
525                    'property',
526                    [ 'name' => 'jump-id' ],
527                    (string)$this->getId()
528                ) . "\n";
529        } else {
530            $s .= Xml::element( 'auth', [], $this->authType ) . "\n";
531        }
532
533        foreach ( $this->getQuestions() as $question ) {
534            $s .= $question->getConfXml( $params );
535        }
536        $s .= "</configuration>\n";
537
538        return $s;
539    }
540
541    /**
542     * Get property names which aren't included in an XML dump
543     * @param array $params
544     * @return array
545     */
546    public function getPropertyDumpExclusion( $params = [] ) {
547        if ( empty( $params['private'] ) ) {
548            return [
549                'gpg-encrypt-key',
550                'gpg-sign-key',
551                'gpg-decrypt-key',
552                'openssl-encrypt-key',
553                'openssl-sign-key',
554                'openssl-decrypt-key'
555            ];
556        } else {
557            return [];
558        }
559    }
560
561    /**
562     * Tally the valid votes for this election.
563     * Returns a Status object. On success, the value property will contain a
564     * ElectionTallier object.
565     * @return Status
566     */
567    public function tally() {
568        $tallier = $this->context->newElectionTallier( $this );
569        $status = $tallier->execute();
570        if ( $status->isOK() ) {
571            return Status::newGood( $tallier );
572        } else {
573            return $status;
574        }
575    }
576
577    /**
578     * Get the stored tally results for this election. The caller can use the
579     * returned tallier to format the results in the desired way.
580     *
581     * @param IDatabase $dbr
582     * @return ElectionTallier|bool
583     */
584    public function getTallyFromDb( $dbr ) {
585        $result = $dbr->newSelectQueryBuilder()
586            ->select( 'pr_value' )
587            ->from( 'securepoll_properties' )
588            ->where( [
589                'pr_entity' => $this->getId(),
590                'pr_key' => 'tally-result',
591            ] )
592            ->caller( __METHOD__ )
593            ->fetchField();
594        if ( !$result ) {
595            return false;
596        }
597
598        $tallier = $this->context->newElectionTallier( $this );
599        $tallier->loadJSONResult( json_decode( $result, true ) );
600        return $tallier;
601    }
602
603    /**
604     * Gets the time that the tally completed. If a tally has not been done yet
605     * then this function will return 'false'.
606     *
607     * @param IDatabase $dbr
608     * @return int|bool
609     */
610    public function getTallyResultTimeFromDb( $dbr ) {
611        $time = $dbr->newSelectQueryBuilder()
612            ->select( 'pr_value' )
613            ->from( 'securepoll_properties' )
614            ->where( [
615                'pr_entity' => $this->getId(),
616                'pr_key' => 'tally-result-time',
617            ] )
618            ->caller( __METHOD__ )
619            ->fetchField();
620        if ( !$time ) {
621            return false;
622        }
623
624        return (int)$time;
625    }
626}