def test_contests_create_get_update_multiple( client: FlaskClient, election_id: str, json_contests: List[JSONDict], jurisdiction_ids: List[str], ): rv = put_json(client, f"/election/{election_id}/contest", json_contests) assert_ok(rv) rv = client.get(f"/election/{election_id}/contest") contests = json.loads(rv.data) expected_contests = [{ **contest, "currentRoundStatus": None } for contest in json_contests] assert contests == {"contests": expected_contests} json_contests[0]["name"] = "Changed name" json_contests[1]["isTargeted"] = True json_contests[2]["jurisdictionIds"] = jurisdiction_ids[1:] rv = put_json(client, f"/election/{election_id}/contest", json_contests) assert_ok(rv) rv = client.get(f"/election/{election_id}/contest") contests = json.loads(rv.data) expected_contests = [{ **contest, "currentRoundStatus": None } for contest in json_contests] assert contests == {"contests": expected_contests}
def test_contests_missing_field(client: FlaskClient, election_id: str, jurisdiction_ids: List[str]): contest: JSONDict = { "id": str(uuid.uuid4()), "name": "Contest 1", "isTargeted": True, "choices": [ { "id": str(uuid.uuid4()), "name": "candidate 1", "numVotes": 48121, }, { "id": str(uuid.uuid4()), "name": "candidate 2", "numVotes": 38026, }, ], "totalBallotsCast": 86147, "numWinners": 1, "votesAllowed": 1, "jurisdictionIds": jurisdiction_ids, } for field in contest: invalid_contest = contest.copy() del invalid_contest[field] rv = put_json(client, f"/election/{election_id}/contest", [invalid_contest]) assert rv.status_code == 400 assert json.loads(rv.data) == { "errors": [{ "message": f"'{field}' is a required property", "errorType": "Bad Request", }] } for field in contest["choices"][0]: invalid_contest = contest.copy() invalid_contest_choice = invalid_contest["choices"][0].copy() del invalid_contest_choice[field] invalid_contest["choices"] = [invalid_contest_choice] rv = put_json(client, f"/election/{election_id}/contest", [invalid_contest]) assert rv.status_code == 400 assert json.loads(rv.data) == { "errors": [{ "message": f"'{field}' is a required property", "errorType": "Bad Request", }] }
def test_contests_create_get_update_one(client, election_id, json_contests): contest = json_contests[0] rv = put_json(client, f"/election/{election_id}/contest", [contest]) assert_ok(rv) rv = client.get(f"/election/{election_id}/contest") contests = json.loads(rv.data) expected_contest = {**contest, "currentRoundStatus": None} assert contests == {"contests": [expected_contest]} contest["totalBallotsCast"] = contest["totalBallotsCast"] + 21 contest["numWinners"] = 2 contest["choices"].append({ "id": str(uuid.uuid4()), "name": "candidate 3", "numVotes": 21, }) rv = put_json(client, f"/election/{election_id}/contest", [contest]) assert_ok(rv) rv = client.get(f"/election/{election_id}/contest") contests = json.loads(rv.data) expected_contest = {**contest, "currentRoundStatus": None} assert contests == {"contests": [expected_contest]}
def election_settings(client: FlaskClient, election_id: str): settings = { "electionName": "Test Election", "online": True, "randomSeed": "1234567890", "riskLimit": 10, "state": USState.California, } rv = put_json(client, f"/election/{election_id}/settings", settings) assert_ok(rv)
def test_invalid_additional_property(client: FlaskClient, election_id: str): rv = put_json( client, f"/election/{election_id}/settings", {"electionNameTypo": "An Updated Name"}, ) assert rv.status_code == 400, f"unexpected response: {rv.data}" assert json.loads(rv.data) == { "errors": [{ "message": "Additional properties are not allowed ('electionNameTypo' was unexpected)", "errorType": "Bad Request", }] }
def test_ab_audit_ballot_not_found( client: FlaskClient, election_id: str, jurisdiction_ids: List[str], round_1_id: str, audit_board_round_1_ids: List[str], ): set_logged_in_user(client, UserType.AUDIT_BOARD, audit_board_round_1_ids[0]) rv = put_json( client, f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/ballots/not-a-real-ballot-id", {}, ) assert rv.status_code == 404
def test_audit_board_contests_list_empty( client: FlaskClient, election_id: str, jurisdiction_ids: List[str], round_1_id: str, audit_board_round_1_ids: List[str], json_contests: List[JSONDict], ): rv = put_json(client, f"/election/{election_id}/contest", [json_contests[1]]) assert_ok(rv) set_logged_in_user(client, UserType.AUDIT_BOARD, user_key=audit_board_round_1_ids[0]) rv = client.get( f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/contest" ) assert json.loads(rv.data) == {"contests": []}
def test_invalid_risk_limit(client: FlaskClient, election_id: str): # Get the existing data. rv = client.get(f"/election/{election_id}/settings") # Set an invalid state. election = json.loads(rv.data) election["riskLimit"] = -1 # Attempt to write invalid data. rv = put_json(client, f"/election/{election_id}/settings", election) assert rv.status_code == 400, f"unexpected response: {rv.data}" compare_json( json.loads(rv.data), { "errors": [{ "message": "-1 is less than the minimum of 1", "errorType": "Bad Request", }] }, )
def test_update_election(client: FlaskClient, election_id: str): # Get the existing data. rv = client.get(f"/election/{election_id}/settings") # Update the values. election = json.loads(rv.data) election["electionName"] = "An Updated Name" election["online"] = True election["randomSeed"] = "a new random seed" election["riskLimit"] = 15 election["state"] = USState.Mississippi rv = put_json(client, f"/election/{election_id}/settings", election) assert_ok(rv) election_record = Election.query.filter_by(id=election_id).one() assert election_record.election_name == "An Updated Name" assert election_record.online is True assert election_record.random_seed == "a new random seed" assert election_record.risk_limit == 15 assert election_record.state == USState.Mississippi
def test_invalid_state(client: FlaskClient, election_id: str): # Get the existing data. rv = client.get(f"/election/{election_id}/settings") # Set an invalid state. election = json.loads(rv.data) election["state"] = "XX" # Attempt to write invalid data. rv = put_json(client, f"/election/{election_id}/settings", election) assert rv.status_code == 400, f"unexpected response: {rv.data}" compare_json( json.loads(rv.data), { "errors": [{ "message": asserts_startswith("'XX' is not one of ['AL',"), "errorType": "Bad Request", }] }, )
def test_audit_board_contests_list( client: FlaskClient, election_id: str, jurisdiction_ids: List[str], round_1_id: str, audit_board_round_1_ids: List[str], json_contests: List[JSONDict], ): rv = put_json(client, f"/election/{election_id}/contest", json_contests) assert_ok(rv) set_logged_in_user(client, UserType.AUDIT_BOARD, user_key=audit_board_round_1_ids[0]) rv = client.get( f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/contest" ) contests = json.loads(rv.data) expected_contests = [{ **contest, "currentRoundStatus": None } for contest in [json_contests[0], json_contests[2]]] assert contests == {"contests": expected_contests}
def test_ab_audit_ballot_invalid( client: FlaskClient, election_id: str, jurisdiction_ids: List[str], contest_ids: List[str], round_1_id: str, audit_board_round_1_ids: List[str], ): set_logged_in_user(client, UserType.AUDIT_BOARD, audit_board_round_1_ids[0]) rv = client.get( f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/ballots" ) ballots = json.loads(rv.data)["ballots"] ballot = ballots[0] choice_id = ContestChoice.query.filter_by( contest_id=contest_ids[0]).first().id for missing_field in ["status", "interpretations"]: audit_request = { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", }], } del audit_request[missing_field] rv = put_json( client, f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/ballots/{ballot['id']}", audit_request, ) assert rv.status_code == 400 assert json.loads(rv.data) == { "errors": [{ "errorType": "Bad Request", "message": f"'{missing_field}' is a required property", }] } for missing_field in [ "contestId", "interpretation", "choiceId", "comment" ]: interpretation = { "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", } del interpretation[missing_field] rv = put_json( client, f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/ballots/{ballot['id']}", { "status": "AUDITED", "interpretations": [interpretation], }, ) assert rv.status_code == 400 assert json.loads(rv.data) == { "errors": [{ "errorType": "Bad Request", "message": f"'{missing_field}' is a required property", }] } invalid_requests = [ ( { "status": "audited", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", }], }, "'audited' is not one of ['NOT_AUDITED', 'AUDITED', 'NOT_FOUND']", ), ( { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "vote", "choiceId": choice_id, "comment": "blah blah blah", }], }, "'vote' is not one of ['BLANK', 'CANT_AGREE', 'VOTE']", ), ( { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": None, "comment": "blah blah blah", }], }, f"Must include choiceId with interpretation VOTE for contest {contest_ids[0]}", ), ( { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": "", "comment": "blah blah blah", }], }, f"Must include choiceId with interpretation VOTE for contest {contest_ids[0]}", ), ( { "status": "AUDITED", "interpretations": [{ "contestId": "12345", "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", }], }, "Contest not found: 12345", ), ( { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": "12345", "comment": "blah blah blah", }], }, "Contest choice not found: 12345", ), ( { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[1], "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", }], }, f"Contest choice {choice_id} is not associated with contest {contest_ids[1]}", ), ( { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "BLANK", "choiceId": choice_id, "comment": "blah blah blah", }], }, f"Cannot include choiceId with interpretation BLANK for contest {contest_ids[0]}", ), ( { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "CANT_AGREE", "choiceId": choice_id, "comment": "blah blah blah", }], }, f"Cannot include choiceId with interpretation CANT_AGREE for contest {contest_ids[0]}", ), ( { "status": "AUDITED", "interpretations": [], }, "Must include interpretations with ballot status AUDITED.", ), ( { "status": "NOT_FOUND", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", }], }, "Cannot include interpretations with ballot status NOT_FOUND.", ), ( { "status": "NOT_AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", }], }, "Cannot include interpretations with ballot status NOT_AUDITED.", ), ] for (invalid_request, expected_message) in invalid_requests: rv = put_json( client, f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/ballots/{ballot['id']}", invalid_request, ) assert rv.status_code == 400 assert json.loads(rv.data) == { "errors": [{ "errorType": "Bad Request", "message": expected_message }] }
def test_ja_ballots_round_1( client: FlaskClient, election_id: str, jurisdiction_ids: List[str], contest_ids: str, round_1_id: str, audit_board_round_1_ids: List[str], # pylint: disable=unused-argument ): set_logged_in_user(client, UserType.JURISDICTION_ADMIN, DEFAULT_JA_EMAIL) rv = client.get( f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/ballots" ) ballots = json.loads(rv.data)["ballots"] assert len(ballots) == J1_BALLOTS_ROUND_1 compare_json( ballots[0], { "id": assert_is_id, "auditBoard": { "id": assert_is_id, "name": "Audit Board #1" }, "batch": { "id": assert_is_id, "name": "4", "tabulator": None }, "position": 8, "status": "NOT_AUDITED", "interpretations": [], }, ) ballot_with_wrong_status = next( (b for b in ballots if b["status"] != "NOT_AUDITED"), None) assert ballot_with_wrong_status is None assert ballots == sorted( ballots, key=lambda b: (b["auditBoard"]["name"], b["batch"]["name"], b["position"]), ) # Try auditing one ballot set_logged_in_user(client, UserType.AUDIT_BOARD, audit_board_round_1_ids[0]) choice_id = ContestChoice.query.filter_by( contest_id=contest_ids[0]).first().id rv = put_json( client, f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/ballots/{ballots[0]['id']}", { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", }], }, ) assert_ok(rv) set_logged_in_user(client, UserType.JURISDICTION_ADMIN, DEFAULT_JA_EMAIL) rv = client.get( f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/ballots" ) ballots = json.loads(rv.data)["ballots"] compare_json( ballots[0], { "id": assert_is_id, "auditBoard": { "id": assert_is_id, "name": "Audit Board #1" }, "batch": { "id": assert_is_id, "name": "4", "tabulator": None }, "position": 8, "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", }], }, )
def test_contest_too_many_votes(client: FlaskClient, election_id: str): contest = { "id": str(uuid.uuid4()), "name": "Contest 1", "isTargeted": True, "choices": [ { "id": str(uuid.uuid4()), "name": "candidate 1", "numVotes": 400, }, { "id": str(uuid.uuid4()), "name": "candidate 2", "numVotes": 101, }, ], "totalBallotsCast": 500, "numWinners": 1, "votesAllowed": 1, "jurisdictionIds": [], } rv = put_json(client, f"/election/{election_id}/contest", [contest]) assert rv.status_code == 400 assert json.loads(rv.data) == { "errors": [{ "message": "Too many votes cast in contest: Contest 1 (501 votes, 500 allowed)", "errorType": "Bad Request", }] } contest = { "id": str(uuid.uuid4()), "name": "Contest 1", "isTargeted": True, "choices": [ { "id": str(uuid.uuid4()), "name": "candidate 1", "numVotes": 700, }, { "id": str(uuid.uuid4()), "name": "candidate 2", "numVotes": 301, }, ], "totalBallotsCast": 500, "numWinners": 1, "votesAllowed": 2, "jurisdictionIds": [], } rv = put_json(client, f"/election/{election_id}/contest", [contest]) assert rv.status_code == 400 assert json.loads(rv.data) == { "errors": [{ "message": "Too many votes cast in contest: Contest 1 (1001 votes, 1000 allowed)", "errorType": "Bad Request", }] }
def test_contests_round_status( client: FlaskClient, election_id: str, json_contests: List[JSONDict], election_settings, # pylint: disable=unused-argument manifests, # pylint: disable=unused-argument ): rv = put_json(client, f"/election/{election_id}/contest", json_contests) assert_ok(rv) rv = post_json( client, f"/election/{election_id}/round", { "roundNum": 1, "sampleSize": SAMPLE_SIZE_ROUND_1 }, ) assert_ok(rv) rv = client.get(f"/election/{election_id}/contest") contests = json.loads(rv.data)["contests"] assert contests[0]["currentRoundStatus"] == { "isRiskLimitMet": None, "numBallotsSampled": SAMPLE_SIZE_ROUND_1, } assert contests[1]["currentRoundStatus"] == { "isRiskLimitMet": None, "numBallotsSampled": 0, } assert contests[2]["currentRoundStatus"] == { "isRiskLimitMet": None, "numBallotsSampled": 81, } # Fake that one opportunistic contest met its risk limit, but the targeted # contest did not opportunistic_round_contest = RoundContest.query.filter_by( contest_id=contests[1]["id"]).one() opportunistic_round_contest.is_complete = True targeted_round_contest = RoundContest.query.filter_by( contest_id=contests[0]["id"]).one() targeted_round_contest.is_complete = False db.session.commit() rv = client.get(f"/election/{election_id}/contest") contests = json.loads(rv.data)["contests"] assert contests[0]["currentRoundStatus"] == { "isRiskLimitMet": False, "numBallotsSampled": SAMPLE_SIZE_ROUND_1, } assert contests[1]["currentRoundStatus"] == { "isRiskLimitMet": True, "numBallotsSampled": 0, } assert contests[2]["currentRoundStatus"] == { "isRiskLimitMet": None, "numBallotsSampled": 81, }
def contest_ids(client: FlaskClient, election_id: str, jurisdiction_ids: List[str]) -> List[str]: contests = [ { "id": str(uuid.uuid4()), "name": "Contest 1", "isTargeted": True, "choices": [ { "id": str(uuid.uuid4()), "name": "candidate 1", "numVotes": 600, }, { "id": str(uuid.uuid4()), "name": "candidate 2", "numVotes": 400, }, ], "totalBallotsCast": 1000, "numWinners": 1, "votesAllowed": 1, "jurisdictionIds": jurisdiction_ids, }, { "id": str(uuid.uuid4()), "name": "Contest 2", "isTargeted": False, "choices": [ { "id": str(uuid.uuid4()), "name": "candidate 1", "numVotes": 200, }, { "id": str(uuid.uuid4()), "name": "candidate 2", "numVotes": 300, }, { "id": str(uuid.uuid4()), "name": "candidate 3", "numVotes": 100, }, ], "totalBallotsCast": 600, "numWinners": 2, "votesAllowed": 2, "jurisdictionIds": jurisdiction_ids[:2], }, ] rv = put_json(client, f"/election/{election_id}/contest", contests) assert_ok(rv) return [str(c["id"]) for c in contests]
def test_jurisdictions_round_status_offline( client: FlaskClient, election_id: str, jurisdiction_ids: List[str], contest_ids: List[str], # pylint: disable=unused-argument election_settings, # pylint: disable=unused-argument manifests, # pylint: disable=unused-argument ): # Change the settings to offline settings = { "electionName": "Test Election", "online": False, "randomSeed": "1234567890", "riskLimit": 10, "state": USState.California, } rv = put_json(client, f"/election/{election_id}/settings", settings) assert_ok(rv) rv = post_json( client, f"/election/{election_id}/round", { "roundNum": 1, "sampleSize": SAMPLE_SIZE_ROUND_1 }, ) assert_ok(rv) rv = client.get(f"/election/{election_id}/jurisdiction") jurisdictions = json.loads(rv.data)["jurisdictions"] assert jurisdictions[0]["currentRoundStatus"] == { "status": "NOT_STARTED", "numBallotsSampled": J1_SAMPLES_ROUND_1, "numBallotsAudited": 0, } # Simulate creating an audit board rv = client.get(f"/election/{election_id}/round") round = json.loads(rv.data)["rounds"][0] ballots = (SampledBallot.query.join(SampledBallotDraw).filter_by( round_id=round["id"]).all()) audit_board_1 = AuditBoard( id=str(uuid.uuid4()), jurisdiction_id=jurisdiction_ids[0], round_id=round["id"], sampled_ballots=ballots[:AB1_SAMPLES + 1], ) db.session.add(audit_board_1) db.session.commit() rv = client.get(f"/election/{election_id}/jurisdiction") jurisdictions = json.loads(rv.data)["jurisdictions"] assert jurisdictions[0]["currentRoundStatus"] == { "status": "IN_PROGRESS", "numBallotsSampled": J1_SAMPLES_ROUND_1, "numBallotsAudited": 0, } # Simulate the audit board signing off audit_board_1 = db.session.merge(audit_board_1) # Reload into the session audit_board_1.signed_off_at = datetime.utcnow() db.session.commit() rv = client.get(f"/election/{election_id}/jurisdiction") jurisdictions = json.loads(rv.data)["jurisdictions"] assert jurisdictions[0]["currentRoundStatus"] == { "status": "COMPLETE", "numBallotsSampled": J1_SAMPLES_ROUND_1, "numBallotsAudited": J1_SAMPLES_ROUND_1, }
def test_ab_audit_ballot_happy_path( client: FlaskClient, election_id: str, jurisdiction_ids: List[str], contest_ids: List[str], round_1_id: str, audit_board_round_1_ids: List[str], ): set_logged_in_user(client, UserType.AUDIT_BOARD, audit_board_round_1_ids[0]) rv = client.get( f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/ballots" ) ballots = json.loads(rv.data)["ballots"] ballot = ballots[0] choice_id = ContestChoice.query.filter_by( contest_id=contest_ids[0]).first().id audit_requests: List[JSONDict] = [ { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": choice_id, "comment": "blah blah blah", }], }, { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "BLANK", "choiceId": None, "comment": None, }], }, { "status": "AUDITED", "interpretations": [{ "contestId": contest_ids[0], "interpretation": "CANT_AGREE", "choiceId": None, "comment": None, }], }, { "status": "NOT_AUDITED", "interpretations": [], }, { "status": "NOT_FOUND", "interpretations": [], }, { "status": "AUDITED", "interpretations": [ { "contestId": contest_ids[0], "interpretation": "VOTE", "choiceId": choice_id, "comment": None, }, { "contestId": contest_ids[1], "interpretation": "CANT_AGREE", "choiceId": None, "comment": "weird scribble", }, ], }, ] for audit_request in audit_requests: rv = put_json( client, f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/ballots/{ballot['id']}", audit_request, ) assert_ok(rv) rv = client.get( f"/election/{election_id}/jurisdiction/{jurisdiction_ids[0]}/round/{round_1_id}/audit-board/{audit_board_round_1_ids[0]}/ballots" ) ballots = json.loads(rv.data)["ballots"] ballots[0]["interpretations"] = sorted(ballots[0]["interpretations"], key=lambda i: i["contestId"]) audit_request["interpretations"] = sorted( audit_request["interpretations"], key=lambda i: i["contestId"]) assert ballots[0] == {**ballot, **audit_request}