clear-election-sdk 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/.travis.yml +4 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +22 -0
- data/README.md +57 -0
- data/Rakefile +7 -0
- data/clear-election-sdk.gemspec +31 -0
- data/lib/clear-election-sdk.rb +17 -0
- data/lib/clear-election-sdk/ballot.rb +156 -0
- data/lib/clear-election-sdk/election.rb +150 -0
- data/lib/clear-election-sdk/factory.rb +80 -0
- data/lib/clear-election-sdk/rspec.rb +129 -0
- data/lib/clear-election-sdk/schema.rb +43 -0
- data/lib/clear-election-sdk/version.rb +3 -0
- data/schemas/api/booth-agent-0.0.schema.json +91 -0
- data/schemas/ballot-0.0.schema.json +33 -0
- data/schemas/election-0.0.schema.json +60 -0
- data/spec/ballot_spec.rb +94 -0
- data/spec/clear_election_spec.rb +11 -0
- data/spec/election_spec.rb +49 -0
- data/spec/rspec_spec.rb +152 -0
- data/spec/schema_spec.rb +9 -0
- data/spec/spec_helper.rb +42 -0
- metadata +202 -0
@@ -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,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
|
+
|