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.
@@ -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
+