/ tests / SigninModelTest.php
<?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");
    }
}
X