<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\SigninModel;
use seekquarry\yioop\models\ProfileModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;
/**
* Tests the one-time sign-in code storage on SigninModel: storing a code,
* reading it back, replacing it when a new one is requested, counting
* wrong guesses, and deleting it so it can only ever be used once. It also
* tests the LDAP account lookups: turning the email a directory returns
* into the Yioop account that owns it, finding emails shared by more than
* one account, and reporting whether an email is already taken by another
* account. Each test runs against a fresh throwaway sqlite file so the
* behavior is exercised in isolation.
*
* @author Chris Pollett
*/
class SigninModelTest extends UnitTest
{
/**
* Filesystem path for the throwaway test DB
* @var string
*/
public $db_path;
/**
* Model under test
* @var SigninModel
*/
public $model;
/**
* Sets up an empty DB holding the SIGNIN_CODE table for the one-time
* code tests and a small USERS table for the LDAP email-to-account
* lookup tests, then hands it to a fresh model instance.
*/
public function setUp()
{
$tag = getmypid() . "_" . random_int(1000, 9999);
$this->db_path = C\WORK_DIRECTORY . "/temp/" .
"signin_code_test_$tag.db";
$db = new Sqlite3Manager();
$db->connect("", "", "", $this->db_path);
/* Build the tables from ProfileModel's definitions, the same ones the
live database uses, so this test tracks any schema change rather
than hand-copied CREATE TABLEs that could drift. */
$dbinfo = ["DBMS" => "Sqlite3", "DB_HOST" => ""];
$profile = new ProfileModel(C\DB_NAME, false);
$profile->initializeSql($db, $dbinfo);
foreach (['SIGNIN_CODE', 'USERS'] as $table) {
$db->execute($profile->create_statements[$table]);
}
$this->model = new SigninModel(C\DB_NAME, false);
$this->model->db = $db;
}
/**
* Disconnects and removes the throwaway DB.
*/
public function tearDown()
{
if ($this->model && $this->model->db) {
$this->model->db->disconnect();
unset(Sqlite3Manager::$active_connections[
$this->model->db->connect_string]);
}
if ($this->db_path && file_exists($this->db_path)) {
unlink($this->db_path);
}
}
/**
* Counting must work out each witness's value from the random one the
* ballot drew when it started, so the ballot has to give that value
* back by witness position. A position the ballot does not have gives
* nothing rather than a wrong value.
*/
public function witnessSeedKeptTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_seed_" . getmypid() .
".txt";
$first = random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES);
$second = random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES);
file_put_contents($path, "-----SEEDS-----\n" .
base64_encode($first) . "\n" . base64_encode($second) .
"\n-----KEY-----\nsomething\n");
$this->assertEqual($first, $model->witnessSeedKept($path, 0),
"the first witness's value comes back");
$this->assertEqual($second, $model->witnessSeedKept($path, 1),
"the second witness's value comes back");
$this->assertTrue(!$model->witnessSeedKept($path, 7),
"a witness the ballot does not have gives nothing");
$this->assertTrue(!$model->witnessSeedKept($path, false),
"a name that is not a witness gives nothing");
unlink($path);
}
/**
* A witness who gave their password by mail is not present when the
* ballot is counted, so the value their password makes may be handed
* in already worked out. Doing that must build the same keys as
* having the password itself.
*/
public function keysFromWorkedOutValuesTestCase()
{
$model = $this->model;
$witnesses = ["alice", "bob"];
$passwords = ["one", "two"];
$seeds = [random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES),
random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES),
random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES)];
list(, $with_password) = $model->createWitnessKeyPair($witnesses,
$passwords, $seeds);
$hashes = [];
foreach ($witnesses as $at => $witness) {
$hashes[$at] = hash('sha256', $seeds[$at] . $witness .
$passwords[$at], true);
}
list(, $with_values) = $model->createWitnessKeyPair($witnesses,
[], $seeds, $hashes);
$this->assertEqual($with_password, $with_values,
"worked-out values build the same public key as passwords");
}
/**
* The check that says whether anything read a round's key file is
* only as good as the file system under it, so the site has to be
* able to say whether a read moves an access time there and warn
* where it does not. The answer must be one of three known ones, and
* it must not leave its probe file behind.
*/
public function keyFileWatchfulnessTestCase()
{
$model = $this->model;
$before = count(glob(C\TEMP_DIR . "/*.txt"));
$said = $model->keyFileWatchfulness();
$this->assertTrue(in_array($said,
["watched", "unwatched", "unknown"]),
"the answer is one the caller knows how to read");
$this->assertEqual($before, count(glob(C\TEMP_DIR . "/*.txt")),
"asking leaves no file behind");
$this->assertEqual($said, $model->keyFileWatchfulness(),
"asking twice gives the same answer");
}
/**
* Once every witness has contributed, the ballot's keys are made from
* the parts they left, and the round is then put beyond reach. A
* round whose key file has been read gives nothing back, since the
* parts could have been taken and the ballot has to be begun again.
*/
public function finishWitnessRoundTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_finish_" . getmypid() .
".txt";
$page_hash = "finish" . getmypid();
$witnesses = ["alice", "bob"];
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$public = $model->openWitnessRound($path, $page_hash);
$model->keepWitnessShare($path, "alice", $public,
random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES),
hash('sha256', "alice-part", true));
$this->assertTrue(!$model->finishWitnessRound($path, $page_hash,
$witnesses), "a round short of a witness is not finished");
$model->keepWitnessShare($path, "bob", $public,
random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES),
hash('sha256', "bob-part", true));
$made = $model->finishWitnessRound($path, $page_hash, $witnesses);
$this->assertTrue($made !== false,
"a round every witness has answered is finished");
$this->assertEqual(3, count($made[0]),
"one value for each witness and one for the seed");
$this->assertEqual(SODIUM_CRYPTO_BOX_PUBLICKEYBYTES,
strlen($made[1]), "a public key comes back");
$model->closeWitnessRound($path, $page_hash);
$this->assertTrue(!file_exists($path),
"closing the round takes its file away");
}
/**
* A round whose key file has been read must not be finished, because
* the parts waiting in it could have been opened by somebody else.
*/
public function finishRefusesAReadRoundTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_read_" . getmypid() .
".txt";
$page_hash = "read" . getmypid();
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$public = $model->openWitnessRound($path, $page_hash);
$model->keepWitnessShare($path, "alice", $public,
random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES),
hash('sha256', "alice-part", true));
$key_file = $model->witnessRoundKeyFile($page_hash);
touch($key_file, filemtime($key_file), time() + 60);
clearstatcache(true, $key_file);
$this->assertTrue(!$model->finishWitnessRound($path, $page_hash,
["alice"]), "a round whose key was read is refused");
$model->closeWitnessRound($path, $page_hash);
$this->assertTrue(!file_exists($path),
"closing the round takes its file away");
}
/**
* A witness acting by mail is not at the form when the others are, so
* their part waits in the ballot's secrets file. It must wait sealed
* to the round's public key, whose secret half sits in a file of its
* own, so what waits in the secrets file is of no use to a reader of
* that file alone.
*/
public function witnessRoundSealsPartsTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_round_" . getmypid() .
".txt";
$page_hash = "hash" . getmypid();
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$public = $model->openWitnessRound($path, $page_hash);
$model->keepWitnessShare($path, "alice", $public, "rand-a",
"hash-a");
$model->keepWitnessShare($path, "bob", $public, "rand-b",
"hash-b");
$said = file_get_contents($path);
$this->assertTrue(strpos($said, "hash-a") === false,
"no part is in the file as itself");
$this->assertTrue(strpos($said, "-----SEEDS-----") !== false,
"what the file already held is still there");
$opened = $model->openWitnessShares($path, $page_hash);
$this->assertEqual(2, count($opened), "both parts open");
$this->assertEqual("hash-a", $opened["alice"]["hash"],
"the first witness's value comes back");
$this->assertEqual("rand-b", $opened["bob"]["random"],
"the second witness's random value comes back");
$model->closeWitnessRound($path, $page_hash);
$this->assertTrue(!file_exists($path),
"closing the round takes its file away");
$this->assertTrue(!file_exists(
$model->witnessRoundKeyFile($page_hash)),
"closing the round deletes the key file");
}
/**
* A file written once has the same access time as change time, and
* several file systems move an access time on a read only when it
* already lags the change time. A round's key file must therefore be
* left with its access time behind, or a reader would never show.
*/
public function keyFileIsLeftAbleToReportAReadTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_lag_" . getmypid() .
".txt";
$page_hash = "lag" . getmypid();
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$model->openWitnessRound($path, $page_hash);
$key_file = $model->witnessRoundKeyFile($page_hash);
clearstatcache(true, $key_file);
$this->assertTrue(fileatime($key_file) < filemtime($key_file),
"the key file's access time lags its change time");
$model->closeWitnessRound($path, $page_hash);
}
/**
* Either time moving means somebody has been at the key: a read moves
* the access time, a write moves the change time. A file system that
* will not report a read still reports a change, so both are watched.
*/
public function roundNoticesAChangeAsWellAsAReadTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_change_" . getmypid() .
".txt";
$page_hash = "change" . getmypid();
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$model->openWitnessRound($path, $page_hash);
$key_file = $model->witnessRoundKeyFile($page_hash);
$this->assertTrue($model->witnessRoundIsIntact($path, $page_hash),
"a round nothing has read or changed is still good");
clearstatcache(true, $key_file);
touch($key_file, filemtime($key_file) + 60, fileatime($key_file));
clearstatcache(true, $key_file);
$this->assertTrue(!$model->witnessRoundIsIntact($path,
$page_hash), "a key file that was written to is not intact");
$model->closeWitnessRound($path, $page_hash);
}
/**
* A round whose key file has been read by somebody is one where the
* parts could have been opened, so it must be refused rather than
* finished. The check is the key file's access time against the one
* written down when the round opened.
*/
public function witnessRoundNoticesAReaderTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_touch_" . getmypid() .
".txt";
$page_hash = "touch" . getmypid();
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$model->openWitnessRound($path, $page_hash);
$this->assertTrue($model->witnessRoundIsIntact($path, $page_hash),
"a round nothing has read or changed is still good");
$key_file = $model->witnessRoundKeyFile($page_hash);
touch($key_file, filemtime($key_file), time() + 60);
clearstatcache(true, $key_file);
$this->assertTrue(!$model->witnessRoundIsIntact($path,
$page_hash), "a round whose key file was read is not");
$model->closeWitnessRound($path, $page_hash);
$this->assertTrue(!$model->witnessRoundIsIntact($path,
$page_hash), "a round with no key file at all is not");
}
/**
* Sending a fresh set of invitations must end the set before it, so a
* witness holding an older mail finds its link no longer opens the
* ballot. The nonce every invitation carries is what does that: asking
* again without sending gives the same one, and sending gives a new
* one that the older links no longer match.
*/
public function ballotInviteNonceTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_nonce_" . getmypid() .
".txt";
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$first = $model->ballotInviteNonce($path);
$this->assertTrue($first !== "", "a ballot is given a nonce");
$this->assertEqual($first, $model->ballotInviteNonce($path),
"asking again without sending gives the same one");
$later = time() + 3600;
$token = $model->ballotInviteToken(7, "b1", "alice", "start",
$later, $first);
$second = $model->ballotInviteNonce($path, true);
$this->assertTrue($first !== $second,
"sending a fresh set gives a different nonce");
$this->assertTrue(!$model->ballotInviteIsGood($token, 7, "b1",
"alice", "start", $later, $second),
"a link from the older set no longer opens the ballot");
$this->assertTrue(strpos(file_get_contents($path),
"-----SEEDS-----") !== false,
"what the file already held is still there");
unlink($path);
}
/**
* An invitation to take part in a ballot is good only for the witness,
* ballot and stage it was made for, and only until it expires. A
* witness follows the link in their own time, so a token that has
* grown old, or been altered to name somebody else, must be refused.
*/
public function ballotInviteTestCase()
{
$model = $this->model;
$later = time() + 3600;
$token = $model->ballotInviteToken(7, "b1", "alice", "start",
$later, "nonce-one");
$this->assertTrue($model->ballotInviteIsGood($token, 7, "b1",
"alice", "start", $later, "nonce-one"),
"the invitation as it was sent is good");
$this->assertTrue(!$model->ballotInviteIsGood($token, 7, "b1",
"bob", "start", $later, "nonce-one"),
"naming another witness is refused");
$this->assertTrue(!$model->ballotInviteIsGood($token, 7, "b1",
"alice", "count", $later, "nonce-one"),
"naming another stage is refused");
$this->assertTrue(!$model->ballotInviteIsGood($token, 8, "b1",
"alice", "start", $later, "nonce-one"),
"naming another page is refused");
$this->assertTrue(!$model->ballotInviteIsGood($token, 7, "b2",
"alice", "start", $later, "nonce-one"),
"naming another ballot is refused");
$this->assertTrue(!$model->ballotInviteIsGood($token, 7, "b1",
"alice", "start", $later, "nonce-two"),
"another nonce is refused");
$this->assertTrue(!$model->ballotInviteIsGood($token, 7, "b1",
"alice", "start", $later + 60, "nonce-one"),
"moving the expiry later is refused");
$this->assertTrue(!$model->ballotInviteIsGood($token, 7, "b1",
"alice", "start", $later, "nonce-one", $later + 1),
"an invitation past its expiry is refused");
}
/**
* A witness who has taken their part is refused the link that brought
* them, so following it a second time cannot replace what they gave.
* The refusal names having already acted rather than a bad link, since
* the link itself is still the one this site sent.
*/
public function witnessLinkIsRefusedOnceTakenTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_twice_" . getmypid() .
".txt";
$page_hash = "twice" . getmypid();
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$public = $model->openWitnessRound($path, $page_hash);
$nonce = $model->ballotInviteNonce($path);
$later = time() + 3600;
$token = $model->ballotInviteToken(7, "b1", "alice", "start",
$later, $nonce);
$this->assertEqual("", $model->witnessLinkRefusal($path, $token,
7, "b1", "alice", "start", $later),
"a link nobody has followed may be followed");
$model->keepWitnessShare($path, "alice", $public,
random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES),
hash('sha256', "alice-part", true));
$this->assertEqual($model::LINK_TAKEN,
$model->witnessLinkRefusal($path, $token, 7, "b1", "alice",
"start", $later),
"the same link followed again says the part is already taken");
$other = $model->ballotInviteToken(7, "b1", "bob", "start",
$later, $nonce);
$this->assertEqual("", $model->witnessLinkRefusal($path, $other,
7, "b1", "bob", "start", $later),
"a witness who has not acted is still let in");
$model->closeWitnessRound($path, $page_hash);
}
/**
* A ballot made from parts the witnesses left sealed can be counted
* with the passwords those parts were made from. The ballot's key
* has to be built from the value each password made; built from
* anything else, every count of it came back saying the key did not
* match and the votes could not be opened.
*/
public function ballotMadeFromSealedPartsCanBeCountedTestCase()
{
$model = $this->model;
$stem = C\WORK_DIRECTORY . "/temp/ballot_trip_" . getmypid();
$round_filepath = $stem . "_round.txt";
$secrets_filepath = $stem . "_secrets.txt";
$csv_filepath = $stem . "_votes.csv";
$form_hash = "trip" . getmypid();
$witnesses = ["bob", "root"];
$passwords = ["bob-word", "root-word"];
file_put_contents($round_filepath, "");
$public = $model->openWitnessRound($round_filepath, $form_hash);
foreach ($witnesses as $at => $witness) {
$drawn = random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES);
$model->keepWitnessShare($round_filepath, $witness, $public,
$drawn, hash('sha256', $drawn . $witness .
$passwords[$at], true));
}
$made = $model->finishWitnessRound($round_filepath, $form_hash,
$witnesses);
$this->assertTrue($made !== false,
"a round both witnesses have taken part in can be finished");
$model->createBallotFile($secrets_filepath, $form_hash,
$witnesses, [], $made[0], $made[4]);
$model->closeWitnessRound($round_filepath, $form_hash);
$this->assertEqual("SUCCESS",
$model->countBallotFile($secrets_filepath, $csv_filepath,
$form_hash, $witnesses, $passwords),
"and it counts with the passwords its parts were made from");
$this->assertEqual($model::KEY_MISMATCH,
$model->countBallotFile($secrets_filepath, $csv_filepath,
$form_hash, $witnesses, ["bob-word", "another-word"]),
"while a different password still cannot open it");
foreach ([$round_filepath, $secrets_filepath, $csv_filepath] as
$leftover) {
if (file_exists($leftover)) {
unlink($leftover);
}
}
}
/**
* A witness written to moments ago is not written to again. The
* button that invites them is pressed once; a request that reaches
* the site twice would send a second invitation whose fresh nonce
* ends the first, leaving the witness holding a link the site no
* longer knows.
*/
public function witnessWrittenToJustNowIsLeftAloneTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_invited_" . getmypid() .
".txt";
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$this->assertTrue(!$model->witnessInvitedJustNow($path, "alice",
60), "a witness nobody has written to may be written to");
$model->noteWitnessInvited($path, "alice", time());
$this->assertTrue($model->witnessInvitedJustNow($path, "alice",
60), "one written to a moment ago is left alone");
$this->assertTrue(!$model->witnessInvitedJustNow($path, "bob",
60), "and the others are unaffected");
$model->noteWitnessInvited($path, "alice", time() - 120);
$this->assertTrue(!$model->witnessInvitedJustNow($path, "alice",
60), "once the wait has passed they may be written to again");
$this->assertEqual(["alice"],
array_keys($model->witnessInvitesNoted($path)),
"what was written down is read back");
if (file_exists($path)) {
unlink($path);
}
}
/**
* A link is refused once it has expired and once a fresher set of
* invitations has replaced the set it belongs to. A witness may sit on
* their mail for days, and a ballot may be started over in the
* meantime, so both have to turn the older link away.
*/
public function witnessLinkIsRefusedWhenStaleTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_stale_" . getmypid() .
".txt";
$page_hash = "stale" . getmypid();
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$model->openWitnessRound($path, $page_hash);
$nonce = $model->ballotInviteNonce($path);
$gone = time() - 60;
$expired = $model->ballotInviteToken(7, "b1", "alice", "start",
$gone, $nonce);
$this->assertEqual($model::LINK_EXPIRED,
$model->witnessLinkRefusal($path, $expired, 7, "b1", "alice",
"start", $gone),
"a link past its expiry says it has run out");
$later = time() + 3600;
$token = $model->ballotInviteToken(7, "b1", "alice", "start",
$later, $nonce);
$model->ballotInviteNonce($path, true);
$this->assertEqual($model::LINK_BAD,
$model->witnessLinkRefusal($path, $token, 7, "b1", "alice",
"start", $later),
"a link from the set before the newest is refused");
$model->closeWitnessRound($path, $page_hash);
}
/**
* A ballot waits on every witness, so one who never answers leaves it
* unmade. What the witnesses who did answer gave has to keep waiting
* sealed for as long as that takes, and has to be gone once the round
* is given up on.
*/
public function roundWaitsSealedForAWitnessWhoNeverAnswersTestCase()
{
$model = $this->model;
$path = C\WORK_DIRECTORY . "/temp/ballot_waits_" . getmypid() .
".txt";
$page_hash = "waits" . getmypid();
$witnesses = ["alice", "bob"];
file_put_contents($path, "-----SEEDS-----\nc2VlZA==\n");
$public = $model->openWitnessRound($path, $page_hash);
$model->keepWitnessShare($path, "alice", $public,
random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES),
hash('sha256', "alice-part", true));
$this->assertTrue(!$model->finishWitnessRound($path, $page_hash,
$witnesses), "a ballot is not made while a witness is out");
$said = file_get_contents($path);
$this->assertTrue(strpos($said,
hash('sha256', "alice-part", true)) === false,
"what the witness who answered gave is not there to read");
$this->assertTrue(
file_exists($model->witnessRoundKeyFile($page_hash)),
"the round waits on rather than being given up");
$this->assertEqual(1, count($model->witnessSharesKept($path)),
"one part waits and the other has not arrived");
$model->closeWitnessRound($path, $page_hash);
$this->assertTrue(!file_exists($path) &&
!file_exists($model->witnessRoundKeyFile($page_hash)),
"giving up on the round takes both its files away");
}
/**
* Counting rebuilds the ballot's key from what every witness gives the
* second time, so a witness who gives a different password than they
* gave at the start rebuilds a different key. That is refused rather
* than counted, whether the password is typed at the form or its
* worked-out value arrives from a witness who answered by mail.
*/
public function countRefusesWhenAPasswordDiffersTestCase()
{
$model = $this->model;
$stem = C\WORK_DIRECTORY . "/temp/ballot_count_" . getmypid();
$path = $stem . ".txt";
$csv_path = $stem . ".csv";
$form_hash = "count" . getmypid();
$witnesses = ["alice", "bob"];
$passwords = ["alice-word", "bob-word"];
$model->createBallotFile($path, $form_hash, $witnesses, $passwords);
$this->assertEqual("KEY_MISMATCH",
$model->countBallotFile($path, $csv_path, $form_hash,
$witnesses, ["alice-word", "another-word"]),
"a password that differs from the first one is refused");
$seeds = [$model->witnessSeedKept($path, 0),
$model->witnessSeedKept($path, 1)];
$wrong = [1 => hash('sha256', $seeds[1] . "bob" . "another-word",
true)];
$this->assertEqual("KEY_MISMATCH",
$model->countBallotFile($path, $csv_path, $form_hash,
$witnesses, ["alice-word", ""], $wrong),
"a value worked out from the wrong password is refused too");
$right = [1 => hash('sha256', $seeds[1] . "bob" . "bob-word",
true)];
$this->assertEqual("SUCCESS",
$model->countBallotFile($path, $csv_path, $form_hash,
$witnesses, ["alice-word", ""], $right),
"a value worked out from the right password counts it");
$this->assertEqual("SUCCESS",
$model->countBallotFile($path, $csv_path, $form_hash,
$witnesses, $passwords),
"the passwords the ballot began with count it");
foreach ([$path, $csv_path] as $leftover) {
if (file_exists($leftover)) {
unlink($leftover);
}
}
}
/**
* Two invitations to the same witness for the same ballot differ,
* since each carries its own nonce, so one cannot stand for another.
*/
public function ballotInvitesDifferTestCase()
{
$model = $this->model;
$later = time() + 3600;
$first = $model->ballotInviteToken(7, "b1", "alice", "start",
$later, "nonce-one");
$second = $model->ballotInviteToken(7, "b1", "alice", "start",
$later, "nonce-two");
$this->assertTrue($first !== $second,
"two invitations are not the same token");
}
/**
* A stored code can be read back, starting with zero wrong guesses
* and the expiry time it was given.
*/
public function storeAndReadTestCase()
{
$expires = time() + C\SIGNIN_CODE_TIMEOUT;
$this->model->setSigninCode(7, L\crawlCrypt("ABCD2345"), $expires);
$record = $this->model->getSigninCode(7);
$this->assertTrue(!empty($record),
"getSigninCode returns the stored row");
$this->assertEqual(0, intval($record['TRIES']),
"a fresh code starts with zero wrong guesses");
$this->assertEqual($expires, intval($record['EXPIRES']),
"the expiry time is persisted");
}
/**
* The right code matches the stored hash and a wrong one does not,
* which is exactly the check the sign-in flow performs.
*/
public function verifyMatchTestCase()
{
$code = "PQRS7890";
$this->model->setSigninCode(7, L\crawlCrypt($code),
time() + C\SIGNIN_CODE_TIMEOUT);
$record = $this->model->getSigninCode(7);
$this->assertTrue(hash_equals($record['CODE_HASH'],
L\crawlCrypt($code, $record['CODE_HASH'])),
"the right code matches the stored hash");
$this->assertFalse(hash_equals($record['CODE_HASH'],
L\crawlCrypt("WRONG234", $record['CODE_HASH'])),
"a wrong code does not match");
}
/**
* Requesting a new code replaces any earlier one, so only the most
* recent code works and its wrong-guess count starts over.
*/
public function replaceTestCase()
{
$this->model->setSigninCode(7, L\crawlCrypt("FIRST234"),
time() + C\SIGNIN_CODE_TIMEOUT);
$this->model->incrementSigninCodeTries(7);
$this->model->setSigninCode(7, L\crawlCrypt("SECOND34"),
time() + C\SIGNIN_CODE_TIMEOUT);
$record = $this->model->getSigninCode(7);
$this->assertTrue(hash_equals($record['CODE_HASH'],
L\crawlCrypt("SECOND34", $record['CODE_HASH'])),
"only the newest code is stored");
$this->assertEqual(0, intval($record['TRIES']),
"the replacement resets the wrong-guess count");
}
/**
* Each wrong guess is counted so the flow can retire a code once it
* reaches the allowed ceiling.
*/
public function incrementTriesTestCase()
{
$this->model->setSigninCode(7, L\crawlCrypt("CODE2345"),
time() + C\SIGNIN_CODE_TIMEOUT);
$this->model->incrementSigninCodeTries(7);
$this->model->incrementSigninCodeTries(7);
$record = $this->model->getSigninCode(7);
$this->assertEqual(2, intval($record['TRIES']),
"each wrong guess bumps the count by one");
}
/**
* A used code is deleted so the same code can never sign anyone in a
* second time.
*/
public function singleUseDeleteTestCase()
{
$this->model->setSigninCode(7, L\crawlCrypt("ONESHOT2"),
time() + C\SIGNIN_CODE_TIMEOUT);
$this->model->deleteSigninCode(7);
$record = $this->model->getSigninCode(7);
$this->assertTrue(empty($record),
"a deleted code is gone");
}
/**
* A user with no pending code simply has nothing to read.
*/
public function unknownUserTestCase()
{
$record = $this->model->getSigninCode(999);
$this->assertTrue(empty($record),
"there is no row for a user without a code");
}
/**
* Adds one account row to the throwaway USERS table for the LDAP
* lookup tests below.
*
* @param int $user_id the account's numeric id
* @param string $user_name the account's username
* @param string $email the account's email
*/
public function insertUser($user_id, $user_name, $email)
{
$this->model->db->execute("INSERT INTO USERS " .
"(USER_ID, USER_NAME, EMAIL) VALUES (?, ?, ?)",
[$user_id, $user_name, $email]);
}
/**
* The email a directory returns maps to the one account that owns it,
* case-insensitively; an unknown email, an empty email, and an email
* shared by two accounts all map to false so a sign-in is never
* granted to a guessed or ambiguous account.
*/
public function localUserForLdapEmailTestCase()
{
$this->insertUser(1, "jane", "jane@example.com");
$this->assertEqual("jane",
$this->model->localUserForLdapEmail("jane@example.com"),
"an email maps to the account that owns it");
$this->assertEqual("jane",
$this->model->localUserForLdapEmail("JANE@EXAMPLE.COM"),
"the email match ignores letter case");
$this->assertFalse(
$this->model->localUserForLdapEmail("nobody@example.com"),
"an unknown email maps to no account");
$this->assertFalse($this->model->localUserForLdapEmail(""),
"an empty email maps to no account");
$this->insertUser(2, "jane2", "jane@example.com");
$this->assertFalse(
$this->model->localUserForLdapEmail("jane@example.com"),
"an email two accounts share is refused as ambiguous");
}
/**
* The conflict scan lists only emails more than one account holds,
* with the usernames that share each, and skips accounts that have no
* email at all.
*/
public function emailConflictsTestCase()
{
$this->insertUser(1, "jane", "jane@example.com");
$this->insertUser(2, "bob", "bob@example.com");
$this->insertUser(3, "noemail", "");
$this->assertEqual([], $this->model->emailConflicts(),
"no conflict while every email is unique");
$this->insertUser(4, "jane2", "JANE@example.com");
$this->insertUser(5, "noemail2", "");
$conflicts = $this->model->emailConflicts();
$this->assertEqual(1, count($conflicts),
"exactly one email is shared");
$shared = array_values($conflicts)[0];
sort($shared);
$this->assertEqual(["jane", "jane2"], $shared,
"both usernames that share the email are listed");
}
/**
* The quick conflict check is false while every email is unique and
* true once two accounts share one.
*/
public function hasEmailConflictTestCase()
{
$this->insertUser(1, "jane", "jane@example.com");
$this->insertUser(2, "bob", "bob@example.com");
$this->assertFalse($this->model->hasEmailConflict(),
"no conflict while every email is unique");
$this->insertUser(3, "jane2", "jane@example.com");
$this->assertTrue($this->model->hasEmailConflict(),
"a shared email is a conflict");
}
/**
* The readiness check lists every missing setting plus a root account
* with no email when LDAP is otherwise blank, and returns an empty
* list once the three settings are present, the root account has an
* email, and no two accounts share one. A shared email shows up as its
* own problem.
*/
public function ldapConfigProblemsTestCase()
{
$this->assertEqual(
["no_servers", "no_suffix", "no_base_dn", "root_no_email"],
$this->model->ldapConfigProblems("", "", ""),
"blank LDAP with no root email lists every problem");
$this->insertUser(C\ROOT_ID, "root", "root@example.com");
$this->assertEqual([],
$this->model->ldapConfigProblems("ldap.example.com",
"@example.com", "dc=example,dc=com"),
"filled settings with a root email and no clashes are ready");
$this->insertUser(2, "jane", "jane@example.com");
$this->insertUser(3, "jane2", "jane@example.com");
$this->assertEqual(["email_conflicts"],
$this->model->ldapConfigProblems("ldap.example.com",
"@example.com", "dc=example,dc=com"),
"a shared email is the only remaining problem");
}
/**
* Checks that the live root directory check refuses to even try a bind
* when something it needs is missing: a blank root username, a blank
* password, or no directory server to reach. Each returns the
* bind_failed token without contacting a directory, which keeps the site
* on locally stored passwords. The successful bind and email match cannot
* be exercised here because they need a real directory to answer.
*/
public function ldapRootValidationProblemRejectsEmptyTestCase()
{
$this->assertEqual("bind_failed",
$this->model->ldapRootValidationProblem("ldap.example.com",
"@example.com", "dc=example,dc=com", "", "secret"),
"a blank root username cannot validate");
$this->assertEqual("bind_failed",
$this->model->ldapRootValidationProblem("ldap.example.com",
"@example.com", "dc=example,dc=com", "root", ""),
"a blank root password cannot validate");
$this->assertEqual("bind_failed",
$this->model->ldapRootValidationProblem("",
"@example.com", "dc=example,dc=com", "root", "secret"),
"no directory server to reach cannot validate");
}
}