clear-election-sdk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
+
|