clear-election-sdk 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,80 @@
1
+ module ClearElection
2
+ module Factory
3
+ def self.seq(key)
4
+ val = (@seq ||= Hash.new(-1))[key] += 1
5
+ "#{key}-#{val}"
6
+ end
7
+
8
+ def self.election_uri
9
+ "http://test.example.com/elections/#{seq(:id)}"
10
+ end
11
+
12
+ def self.agent_uri(what="agent", host:"agents.example.com")
13
+ "http://#{host}/#{seq(what)}/"
14
+ end
15
+
16
+ def self.election(
17
+ signin: nil,
18
+ booth: nil,
19
+ pollsOpen: nil,
20
+ pollsClose: nil,
21
+ writeIn: true
22
+ )
23
+ one_month = 60*60*24*30
24
+ Election.new(
25
+ name: seq("Election Name"),
26
+ signin: Election::Agent.new(uri: signin || self.agent_uri("signin")),
27
+ booth: Election::Agent.new(uri: booth || self.agent_uri("booth")),
28
+ pollsOpen: (pollsOpen || Time.now - one_month).to_datetime(),
29
+ pollsClose: (pollsClose || Time.now + one_month).to_datetime(),
30
+ contests: [
31
+ Factory.contest(ranked: true, multiplicity: 3, writeIn: writeIn, ncandidates: 3),
32
+ Factory.contest(ncandidates: 2)
33
+ ]
34
+ )
35
+ end
36
+
37
+ def self.contest(ranked: nil, multiplicity: nil, writeIn: nil, ncandidates: 3)
38
+ Election::Contest.new(
39
+ contestId: seq(:contestId),
40
+ name: seq("Contest Name"),
41
+ ranked: ranked,
42
+ multiplicity: multiplicity,
43
+ writeIn: writeIn,
44
+ candidates: ncandidates.times.map{ Election::Candidate.new(candidateId: seq(:candidateId), name: seq("Candidate Name")) }
45
+ )
46
+ end
47
+
48
+ def self.ballot(election=nil, ballotId: nil, uniquifier: nil, demographic: nil, invalid: nil, complete: true)
49
+ election ||= self.election
50
+ contests = election.contests.dup
51
+ contests = contests.drop(1) if not complete
52
+ contests << Factory.contest if invalid == :contestId
53
+ Ballot.new(
54
+ ballotId: ballotId || seq(:ballotId),
55
+ uniquifier: uniquifier || seq(:uniquifier),
56
+ contests: contests.map { |contest|
57
+ options = contest.candidates.map(&:candidateId)
58
+ options << "ABSTAIN"
59
+ options << "WRITEIN: TestWritein" if contest.writeIn
60
+ options.shuffle!
61
+ options.push "Test-Invalid-CandidateId" if invalid == :candidateId
62
+ options.push "WRITEIN: Test-Unpermitted-Writein" if invalid == :writeIn and not contest.writeIn
63
+ options.push options.last if invalid == :duplicateChoice
64
+ nchoices = contest.multiplicity
65
+ nchoices -= 1 if invalid == :multiplicityTooFew
66
+ nchoices += 1 if invalid == :multiplicityTooMany
67
+ Ballot::Contest.new(
68
+ contestId: contest.contestId,
69
+ choices: nchoices.times.map {|rank|
70
+ rank += 1 if invalid == :ranking
71
+ Ballot::Choice.new(candidateId: options.pop, rank: rank)
72
+ }
73
+ )
74
+ },
75
+ demographic: demographic
76
+ )
77
+ end
78
+ end
79
+ end
80
+
@@ -0,0 +1,129 @@
1
+ require "webmock/rspec"
2
+
3
+ require_relative "factory"
4
+
5
+ module ClearElection
6
+ module Rspec
7
+
8
+ def self.setup(agent: false)
9
+ RSpec.configure do |config|
10
+ config.include Helpers
11
+ if agent
12
+ config.before(:each) do
13
+ setup_my_agent_uri
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ module Helpers
20
+ # creates a webmock stub request for an election_uri to return an election
21
+ def stub_election_uri(election: nil, election_uri: nil, booth: nil, signin: nil, pollsOpen: nil, pollsClose: nil, valid: true)
22
+ election_uri ||= ClearElection::Factory.election_uri
23
+ if valid
24
+ election ||= ClearElection::Factory.election(booth: booth, signin: signin, pollsOpen: pollsOpen, pollsClose: pollsClose)
25
+ result = { body: JSON.generate(election.as_json) }
26
+ else
27
+ result = { status: 404 }
28
+ end
29
+ stub_request(:get, election_uri).to_return result
30
+ election_uri
31
+ end
32
+
33
+ # creates a webmock stub for signin with an access token
34
+ def stub_election_access_token(election_uri:, election: nil, accessToken: nil, demographic: nil, valid: true)
35
+ accessToken ||= SecureRandom.hex(10)
36
+ if valid
37
+ result = { status: 200, body: JSON.generate(demographic: demographic) }
38
+ else
39
+ result = { status: 403 }
40
+ end
41
+ election ||= ClearElection.read(election_uri)
42
+ stub_request(:post, election.signin.uri + "redeem").with(body: {election: election_uri, accessToken: accessToken}).to_return result
43
+ accessToken
44
+ end
45
+
46
+ # For use in an agent: create a URI that will act in rspec as if
47
+ # it's URI at which the app was called
48
+ def setup_my_agent_uri
49
+ host! URI(my_agent_uri).host
50
+ allow_any_instance_of(ActionDispatch::Request).to receive(:original_url) { |request|
51
+ request.base_url + URI(my_agent_uri).path + request.original_fullpath
52
+ }
53
+ end
54
+
55
+ def my_agent_uri
56
+ @my_agent_uri ||= ClearElection::Factory.agent_uri(Rails.root.basename)
57
+ end
58
+
59
+ shared_examples "api that verifies election state" do |state|
60
+ describe "verifies election is #{state}" do
61
+
62
+ oneDay = 60*60*24
63
+
64
+ case state
65
+ when :open
66
+ it "rejects if polls have not opened" do
67
+ Timecop.travel(election.pollsOpen - oneDay) do
68
+ api_bound.call
69
+ expect(response).to have_http_status 403
70
+ expect(JSON.parse(response.body)["error"]).to match /open/i
71
+ end
72
+ end
73
+ it "rejects if polls have closed" do
74
+ Timecop.travel(election.pollsClose + oneDay) do
75
+ api_bound.call
76
+ expect(response).to have_http_status 403
77
+ expect(JSON.parse(response.body)["error"]).to match /open/i
78
+ end
79
+ end
80
+
81
+ when :closed
82
+ it "rejects if polls have not closed" do
83
+ Timecop.travel(election.pollsClose - oneDay) do
84
+ api_bound.call
85
+ expect(response).to have_http_status 403
86
+ expect(JSON.parse(response.body)["error"]).to match /closed/i
87
+ end
88
+ end
89
+
90
+ else
91
+ raise "Unknown election verification state #{state.inspect}" unless state == :unopen
92
+ it "rejects if polls have opened" do
93
+ Timecop.travel(election.pollsOpen + oneDay) do
94
+ api_bound.call
95
+ expect(response).to have_http_status 403
96
+ expect(JSON.parse(response.body)["error"]).to match /open/i
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ shared_examples "api that validates election URI" do |state: nil, agent: nil|
104
+
105
+ describe "verifies election URI" do
106
+ it "rejects invalid election URI" do
107
+ apicall.call stub_election_uri(valid: false)
108
+ expect(response).to have_http_status 422
109
+ expect(JSON.parse(response.body)["error"]).to match /uri/i
110
+ end
111
+
112
+ it "rejects if I am not #{agent} agent" do
113
+ apicall.call stub_election_uri() # not passing my_agent_uri
114
+ expect(response).to have_http_status 422
115
+ expect(JSON.parse(response.body)["error"]).to match /#{agent} agent/i
116
+ end if agent
117
+ end
118
+
119
+ let(:election_uri) { stub_election_uri(agent ? { agent => my_agent_uri } : {}) }
120
+
121
+ it_behaves_like "api that verifies election state", state do
122
+ let(:election) { ClearElection.read(election_uri) }
123
+ let(:api_bound) { -> { apicall.call election_uri } }
124
+ end if state
125
+
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,43 @@
1
+ module ClearElection
2
+ module Schema
3
+ extend self
4
+
5
+ def root
6
+ @root ||= Pathname.new(__FILE__).dirname.parent.parent + "schemas"
7
+ end
8
+
9
+ def _get(group:nil, item:, version:, expand: false)
10
+ JSON.parse(File.read(root + (group||"") + "#{item}-#{version}.schema.json")).tap { |json|
11
+ expand_refs!(json) if expand
12
+ }
13
+ end
14
+
15
+ def election(version: ELECTION_SCHEMA_VERSION)
16
+ _get(item: "election", version: version)
17
+ end
18
+
19
+ def ballot(version: BALLOT_SCHEMA_VERSION)
20
+ _get(item: "ballot", version: version)
21
+ end
22
+
23
+ def api(agent, version:)
24
+ _get(group: "api", item: agent, version:version, expand: true)
25
+ end
26
+
27
+ def expand_refs!(json)
28
+ json.tap {
29
+ JSON.recurse_proc json do |item|
30
+ if Hash === item and uri = item['$ref']
31
+ uri = URI.parse(uri)
32
+ if uri.scheme
33
+ source = uri
34
+ source = ClearElection::Schema.root.join uri.path.sub(%r{^/}, '') if uri.scheme == 'file'
35
+ item.delete '$ref'
36
+ item.merge! expand_refs! JSON.parse source.read
37
+ end
38
+ end
39
+ end
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,3 @@
1
+ module ClearElection
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,91 @@
1
+ {
2
+ "definitions": {
3
+ "ballot": { "$ref": "file:/ballot-0.0.schema.json" },
4
+ "session-request": {
5
+ "type": "object",
6
+ "properties": {
7
+ "election": { "type": "string", "format": "uri" },
8
+ "accessToken": { "type": "string" }
9
+ },
10
+ "required": [ "election", "accessToken" ]
11
+ },
12
+ "session-response": {
13
+ "type": "object",
14
+ "properties": {
15
+ "sessionKey": { "type": "string" },
16
+ "ballot": {
17
+ "type": "object",
18
+ "properties": {
19
+ "ballotId": { "type": "string" },
20
+ "uniquifiers": {
21
+ "type": "array",
22
+ "items": { "type": "string" }
23
+ }
24
+ },
25
+ "required": ["ballotId", "uniquifiers" ]
26
+ }
27
+ },
28
+ "required": [ "sessionKey", "ballot" ]
29
+ },
30
+ "cast-request": {
31
+ "type": "object",
32
+ "properties": {
33
+ "sessionKey": { "type": "string" },
34
+ "ballot": { "$ref": "#/definitions/ballot" }
35
+ },
36
+ "required": [ "sessionKey", "ballot" ]
37
+ },
38
+ "cast-response": {
39
+ "type": "null"
40
+ },
41
+ "returns-request": {
42
+ "type": "object",
43
+ "properties": {
44
+ "election": { "type": "string", "format": "uri" }
45
+ },
46
+ "required": [ "election" ]
47
+ },
48
+ "returns-response": {
49
+ "type": "object",
50
+ "properties": {
51
+ "ballotsIssued": { "type": "integer" },
52
+ "ballotsCast": { "type": "integer" },
53
+ "ballots": {
54
+ "type": "array",
55
+ "items": { "$ref": "#/definitions/ballot" }
56
+ }
57
+ },
58
+ "required": [ "ballotsIssued", "ballotsCast", "ballots" ]
59
+ }
60
+ },
61
+
62
+ "links": [
63
+ {
64
+ "description": "Start a session",
65
+ "href": "/session",
66
+ "method": "POST",
67
+ "rel": "create",
68
+ "title": "Session",
69
+ "schema": { "$ref": "#/definitions/session-request" },
70
+ "targetSchema": { "$ref": "#/definitions/session-response" }
71
+ },
72
+ {
73
+ "description": "Cast a ballot",
74
+ "href": "/cast",
75
+ "method": "POST",
76
+ "rel": "create",
77
+ "title": "Cast",
78
+ "schema": { "$ref": "#/definitions/cast-request" },
79
+ "targetSchema": { "$ref": "#/definitions/cast-response" }
80
+ },
81
+ {
82
+ "description": "Get returns",
83
+ "href": "/returns",
84
+ "method": "GET",
85
+ "rel": "list",
86
+ "title": "Cast",
87
+ "schema": { "$ref": "#/definitions/returns-request" },
88
+ "targetSchema": { "$ref": "#/definitions/returns-response" }
89
+ }
90
+ ]
91
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "title": "Ballot",
4
+ "description": "ClearElection Ballot definition, v0.0",
5
+ "type": "object",
6
+ "properties": {
7
+ "version": { "enum": ["0.0"] },
8
+ "ballotId": { "type": "string" },
9
+ "uniquifier": { "type": "string" },
10
+ "contests": {
11
+ "type": "array",
12
+ "items": {
13
+ "type": "object",
14
+ "properties": {
15
+ "contestId": { "type": "string" },
16
+ "choices": {
17
+ "type": "array",
18
+ "items": {
19
+ "type": "object",
20
+ "properties": {
21
+ "candidateId": { "type": "string" }
22
+ },
23
+ "required": ["candidateId"]
24
+ }
25
+ }
26
+ },
27
+ "required": ["contestId", "choices"]
28
+ }
29
+ },
30
+ "demographic": { "type": "object" }
31
+ },
32
+ "required": ["version", "ballotId", "uniquifier", "contests"]
33
+ }
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "title": "Election",
4
+ "description": "ClearElection Election definition, v0.0",
5
+ "type": "object",
6
+ "properties": {
7
+ "version": { "enum": ["0.0"] },
8
+ "name": { "type": "string" },
9
+ "agents": {
10
+ "type": "object",
11
+ "properties": {
12
+ "signin": { "$ref": "#/definitions/agent" },
13
+ "booth": { "$ref": "#/definitions/agent" }
14
+ }
15
+ },
16
+ "schedule": {
17
+ "type": "object",
18
+ "properties": {
19
+ "pollsOpen": { "type": "string", "format": "date-time" },
20
+ "pollsClose": { "type": "string", "format": "date-time" }
21
+ }
22
+ },
23
+ "contests": {
24
+ "type": "array",
25
+ "items": {
26
+ "type": "object",
27
+ "properties": {
28
+ "contestId": { "type": "string" },
29
+ "name": { "type": "string" },
30
+ "ranked": { "type": "boolean", "default": false },
31
+ "multiplicity": { "type": "integer", "default": 1, "minimum": 1 },
32
+ "writeIn": { "type": "boolean", "default": false },
33
+ "candidates": {
34
+ "type": "array",
35
+ "items": {
36
+ "type": "object",
37
+ "properties": {
38
+ "candidateId": { "type": "string" },
39
+ "name": { "type": "string" }
40
+ },
41
+ "required": ["candidateId", "name"]
42
+ }
43
+ }
44
+ },
45
+ "required": ["contestId", "name", "candidates"]
46
+ }
47
+ }
48
+ },
49
+ "required": ["version", "name", "agents", "schedule", "contests"],
50
+ "definitions": {
51
+ "agent": {
52
+ "type": "object",
53
+ "properties": {
54
+ "uri": { "type": "string", "format": "uri" }
55
+ },
56
+ "required": ["uri"]
57
+ }
58
+ }
59
+ }
60
+