kiosk-test-support 0.5.20
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/CHANGELOG.md +149 -0
- data/LICENSE.txt +202 -0
- data/README.md +146 -0
- data/lib/kiosk/story_spec.rb +6 -0
- data/lib/kiosk/story_test.rb +16 -0
- data/lib/kiosk/test_helpers/assistant/events.rb +135 -0
- data/lib/kiosk/test_helpers/assistant.rb +151 -0
- data/lib/kiosk/test_helpers/conformance/checks/declared_shape.rb +91 -0
- data/lib/kiosk/test_helpers/conformance/checks/executes.rb +57 -0
- data/lib/kiosk/test_helpers/conformance/checks/helpers.rb +112 -0
- data/lib/kiosk/test_helpers/conformance/checks/principal_scope.rb +104 -0
- data/lib/kiosk/test_helpers/conformance/checks/routes.rb +94 -0
- data/lib/kiosk/test_helpers/conformance/checks.rb +45 -0
- data/lib/kiosk/test_helpers/conformance/in_process_origin.rb +121 -0
- data/lib/kiosk/test_helpers/conformance/minitest.rb +57 -0
- data/lib/kiosk/test_helpers/conformance/null_origin.rb +55 -0
- data/lib/kiosk/test_helpers/conformance/outcome.rb +25 -0
- data/lib/kiosk/test_helpers/conformance/rspec.rb +100 -0
- data/lib/kiosk/test_helpers/conformance/verb.rb +36 -0
- data/lib/kiosk/test_helpers/conformance.rb +46 -0
- data/lib/kiosk/test_helpers/customer.rb +154 -0
- data/lib/kiosk/test_helpers/descriptor_examples.rb +46 -0
- data/lib/kiosk/test_helpers/errors.rb +55 -0
- data/lib/kiosk/test_helpers/in_process_executor.rb +198 -0
- data/lib/kiosk/test_helpers/in_process_wire.rb +90 -0
- data/lib/kiosk/test_helpers/journey.rb +98 -0
- data/lib/kiosk/test_helpers/kyc.rb +98 -0
- data/lib/kiosk/test_helpers/live_server.rb +56 -0
- data/lib/kiosk/test_helpers/null_executor.rb +95 -0
- data/lib/kiosk/test_helpers/person.rb +52 -0
- data/lib/kiosk/test_helpers/small_equihash.rb +18 -0
- data/lib/kiosk/test_helpers/story.rb +36 -0
- data/lib/kiosk/test_helpers/stripe_mock.rb +146 -0
- data/lib/kiosk/test_helpers/version.rb +7 -0
- data/lib/kiosk/test_helpers/wire.rb +124 -0
- data/lib/kiosk/test_helpers.rb +28 -0
- metadata +205 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# Errors this gem raises, from the journey DSL and the conformance checks.
|
|
6
|
+
module Errors
|
|
7
|
+
class RLSDenied < StandardError; end
|
|
8
|
+
|
|
9
|
+
# No shipped executor raises this yet; `NullExecutor` can.
|
|
10
|
+
class QuotaExceeded < StandardError; end
|
|
11
|
+
|
|
12
|
+
class ExecutorNotConfigured < StandardError
|
|
13
|
+
DEFAULT_MESSAGE = <<~MSG.strip
|
|
14
|
+
Kiosk::TestHelpers has no executor configured.
|
|
15
|
+
|
|
16
|
+
Wire one in your test helper:
|
|
17
|
+
|
|
18
|
+
# spec/spec_helper.rb or test/test_helper.rb
|
|
19
|
+
require "kiosk/test_helpers/in_process_executor"
|
|
20
|
+
Kiosk::TestHelpers.executor = Kiosk::TestHelpers::InProcessExecutor.new
|
|
21
|
+
|
|
22
|
+
For unit-shaped tests, use the bundled NullExecutor:
|
|
23
|
+
|
|
24
|
+
Kiosk::TestHelpers.executor = Kiosk::TestHelpers::NullExecutor.new
|
|
25
|
+
MSG
|
|
26
|
+
|
|
27
|
+
def initialize(message = DEFAULT_MESSAGE)
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class OriginNotConfigured < StandardError
|
|
33
|
+
DEFAULT_MESSAGE = <<~MSG.strip
|
|
34
|
+
Kiosk::TestHelpers::Conformance has no origin configured.
|
|
35
|
+
|
|
36
|
+
Wire one in your test helper:
|
|
37
|
+
|
|
38
|
+
# spec/spec_helper.rb or test/test_helper.rb
|
|
39
|
+
require "kiosk/test_helpers/conformance/in_process_origin"
|
|
40
|
+
Kiosk::TestHelpers::Conformance.origin = Kiosk::TestHelpers::Conformance::InProcessOrigin.new
|
|
41
|
+
|
|
42
|
+
For unit-shaped tests with no Rails and no database, use the bundled
|
|
43
|
+
NullOrigin:
|
|
44
|
+
|
|
45
|
+
Kiosk::TestHelpers::Conformance.origin =
|
|
46
|
+
Kiosk::TestHelpers::Conformance::NullOrigin.new
|
|
47
|
+
MSG
|
|
48
|
+
|
|
49
|
+
def initialize(message = DEFAULT_MESSAGE)
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Needs kiosk-server, which only a Rails host carries; this gem does not depend
|
|
4
|
+
# on it, so a test helper requires this file explicitly:
|
|
5
|
+
# require "kiosk/test_helpers/in_process_executor"
|
|
6
|
+
# Kiosk::TestHelpers.executor = Kiosk::TestHelpers::InProcessExecutor.new
|
|
7
|
+
|
|
8
|
+
require 'date'
|
|
9
|
+
require 'kiosk/server'
|
|
10
|
+
require 'kiosk/test_helpers/errors'
|
|
11
|
+
require 'kiosk/test_helpers/in_process_wire'
|
|
12
|
+
|
|
13
|
+
module Kiosk
|
|
14
|
+
module TestHelpers
|
|
15
|
+
# Runs the kiosk-test-support journey DSL against a real connection: each
|
|
16
|
+
# identity scope sets the GUCs and always rolls back, and an RLS denial
|
|
17
|
+
# surfaces as {Kiosk::TestHelpers::Errors::RLSDenied}. A verb goes through
|
|
18
|
+
# its route, as an assistant calls it.
|
|
19
|
+
class InProcessExecutor
|
|
20
|
+
class NoScopeError < StandardError; end
|
|
21
|
+
|
|
22
|
+
# Aborts the transaction, also on a connection double that ignores `ActiveRecord::Rollback`.
|
|
23
|
+
class RollbackMarker < StandardError; end
|
|
24
|
+
|
|
25
|
+
attr_reader :connection, :system_connection, :current_identity
|
|
26
|
+
|
|
27
|
+
# `system_connection` seeds irrespective of RLS; it defaults to `connection`.
|
|
28
|
+
# `app` is the Rack app a verb call goes through; it defaults to `Rails.application`.
|
|
29
|
+
def initialize(connection: nil, system_connection: nil, app: nil)
|
|
30
|
+
@connection = connection || default_connection
|
|
31
|
+
@system_connection = system_connection || @connection
|
|
32
|
+
@wire = InProcessWire.new(app: app)
|
|
33
|
+
@current_identity = nil
|
|
34
|
+
@scope_depth = 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def in_scope? = @scope_depth.positive?
|
|
38
|
+
|
|
39
|
+
def with_identity(identity, &block)
|
|
40
|
+
# Before touching scope state, so a rescued blockless call leaves it intact.
|
|
41
|
+
raise ArgumentError, 'block required' unless block
|
|
42
|
+
|
|
43
|
+
previous_identity = @current_identity
|
|
44
|
+
@current_identity = identity
|
|
45
|
+
@scope_depth += 1
|
|
46
|
+
begin
|
|
47
|
+
call_rolled_back(identity, block)
|
|
48
|
+
ensure
|
|
49
|
+
@current_identity = previous_identity
|
|
50
|
+
@scope_depth -= 1
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def query(sql)
|
|
55
|
+
require_scope!
|
|
56
|
+
rescue_rls_denials do
|
|
57
|
+
Sql.rows(connection.execute(sql))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A paginated answer returns its rows, as the caller receives them.
|
|
62
|
+
def run_query(name, args) = call_verb(name, :query, args)
|
|
63
|
+
|
|
64
|
+
def run_action(name, args) = call_verb(name, :action, args)
|
|
65
|
+
|
|
66
|
+
# Settlement captures against a PSP, which an always-rolled-back scope cannot host.
|
|
67
|
+
def pay_action(_name, _args)
|
|
68
|
+
require_scope!
|
|
69
|
+
raise NotImplementedError, 'pay is not exercised through the RLS journey DSL; ' \
|
|
70
|
+
'settlement runs via Executor#verb_pay + a kiosk-pay-* provider'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def seed(table, attrs, count:)
|
|
74
|
+
count.times.map do
|
|
75
|
+
Sql.rows(system_connection.execute(Sql.insert(table, attrs))).first
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def default_connection = ::ActiveRecord::Base.lease_connection
|
|
82
|
+
|
|
83
|
+
# A refusal coded `rls_denied` surfaces as {Kiosk::TestHelpers::Errors::RLSDenied}.
|
|
84
|
+
def call_verb(name, kind, args)
|
|
85
|
+
require_scope!
|
|
86
|
+
@wire.call(name, kind: kind, params: args, identity: current_identity)
|
|
87
|
+
rescue Kiosk::Server::Errors::WireError => e
|
|
88
|
+
raise Kiosk::TestHelpers::Errors::RLSDenied, e.message if e.code == 'rls_denied'
|
|
89
|
+
|
|
90
|
+
raise
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def require_scope!
|
|
94
|
+
return if in_scope?
|
|
95
|
+
|
|
96
|
+
raise NoScopeError,
|
|
97
|
+
'call from inside as_user / as_agent / as_anonymous block (default-deny)'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The block's result, or its error re-raised, after the transaction always rolls back.
|
|
101
|
+
def call_rolled_back(identity, block)
|
|
102
|
+
result = caught = nil
|
|
103
|
+
rolled_back do
|
|
104
|
+
apply_gucs(identity) if identity
|
|
105
|
+
result, caught = capture { block.call(self) }
|
|
106
|
+
end
|
|
107
|
+
raise caught if caught
|
|
108
|
+
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def rolled_back
|
|
113
|
+
connection.transaction do
|
|
114
|
+
yield
|
|
115
|
+
raise RollbackMarker
|
|
116
|
+
end
|
|
117
|
+
rescue RollbackMarker
|
|
118
|
+
# The rollback is the point; the marker stops here.
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def capture
|
|
122
|
+
[yield, nil]
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
[nil, e]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def apply_gucs(identity)
|
|
128
|
+
Kiosk::Server::SessionContext.new(connection: connection, identity: identity)
|
|
129
|
+
.guc_statements
|
|
130
|
+
.each { |sql, binds| connection.exec_query(sql, 'Kiosk GUC', binds) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def rescue_rls_denials
|
|
134
|
+
yield
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
raise Kiosk::TestHelpers::Errors::RLSDenied, e.message if Sql.rls_denial?(e)
|
|
137
|
+
|
|
138
|
+
raise
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# SQL literals, result rows, and the errors Postgres raises for an RLS denial.
|
|
142
|
+
module Sql
|
|
143
|
+
module_function
|
|
144
|
+
|
|
145
|
+
def insert(table, attrs)
|
|
146
|
+
cols = attrs.keys
|
|
147
|
+
values_sql = cols.map { |c| quote_value(attrs[c]) }.join(', ')
|
|
148
|
+
col_sql = cols.map { |c| quote_ident(c.to_s) }.join(', ')
|
|
149
|
+
%(INSERT INTO #{quote_ident(table.to_s)} (#{col_sql}) ) +
|
|
150
|
+
%(VALUES (#{values_sql}) RETURNING *)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Result rows with symbol keys.
|
|
154
|
+
def rows(result)
|
|
155
|
+
return [] if result.nil?
|
|
156
|
+
|
|
157
|
+
Array(result).map do |row|
|
|
158
|
+
row.respond_to?(:transform_keys) ? row.transform_keys(&:to_sym) : row
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def rls_denial?(error)
|
|
163
|
+
return true if rls_message?(error.message)
|
|
164
|
+
|
|
165
|
+
cause = error.respond_to?(:cause) ? error.cause : nil
|
|
166
|
+
cause && rls_message?(cause.message)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def rls_message?(message)
|
|
170
|
+
return false if message.nil?
|
|
171
|
+
|
|
172
|
+
message = message.to_s
|
|
173
|
+
message.include?('row-level security') ||
|
|
174
|
+
message.include?('violates row-level') ||
|
|
175
|
+
message.include?('permission denied for table')
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def quote_value(value)
|
|
179
|
+
case value
|
|
180
|
+
when nil then 'NULL'
|
|
181
|
+
when true then 'TRUE'
|
|
182
|
+
when false then 'FALSE'
|
|
183
|
+
when Numeric then value.to_s
|
|
184
|
+
# `getutc`: `Time#utc` mutates and `DateTime#utc` needs ActiveSupport.
|
|
185
|
+
when Time, DateTime then "'#{value.to_time.getutc.iso8601}'"
|
|
186
|
+
when Date then "'#{value.iso8601}'"
|
|
187
|
+
else
|
|
188
|
+
"'#{value.to_s.gsub("'", "''")}'"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def quote_ident(name)
|
|
193
|
+
name.to_s.split('.').map { |part| %("#{part.gsub('"', '""')}") }.join('.')
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Needs kiosk-server, which only a Rails host carries; this gem does not depend
|
|
4
|
+
# on it, so a test helper requires this file explicitly.
|
|
5
|
+
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'openssl'
|
|
8
|
+
require 'rack/mock'
|
|
9
|
+
require 'uri'
|
|
10
|
+
require 'kiosk/server'
|
|
11
|
+
require 'kiosk/test_helpers/wire'
|
|
12
|
+
|
|
13
|
+
module Kiosk
|
|
14
|
+
module TestHelpers
|
|
15
|
+
# Calls a verb of the booted app through its route, in this process, as an
|
|
16
|
+
# assistant does: one linked to the principal's account, as a person links
|
|
17
|
+
# one, and paying any proof-of-work toll. A caller with no identity sends no
|
|
18
|
+
# token.
|
|
19
|
+
class InProcessWire
|
|
20
|
+
# `app` defaults to the Rails application, middleware included.
|
|
21
|
+
def initialize(app: nil)
|
|
22
|
+
@app = app
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The verb's answer, or the wire error its refusal names.
|
|
26
|
+
def call(name, kind:, params:, identity:)
|
|
27
|
+
token = token_for(identity) if identity
|
|
28
|
+
response = request(name, kind, params, token)
|
|
29
|
+
challenges = toll_challenges(response)
|
|
30
|
+
response = request(name, kind, params, token, Wire.solve(challenges)) if challenges
|
|
31
|
+
answer(response)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def app = @app || ::Rails.application
|
|
37
|
+
|
|
38
|
+
def request(name, kind, params, token, proofs = nil)
|
|
39
|
+
env = credentials(token, proofs)
|
|
40
|
+
url = "#{Kiosk.current_issuer.to_s.chomp('/')}#{Kiosk.configuration.mount_path}/#{name}"
|
|
41
|
+
return Rack::MockRequest.new(app).get(query_url(url, params), env) if kind.to_sym == :query
|
|
42
|
+
|
|
43
|
+
Rack::MockRequest.new(app).post(url, env.merge(input: JSON.generate(params), 'CONTENT_TYPE' => 'application/json'))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def credentials(token, proofs)
|
|
47
|
+
{ 'HTTP_AUTHORIZATION' => ("Bearer #{token}" if token),
|
|
48
|
+
'HTTP_KIOSK_POW' => (JSON.generate(proofs) if proofs) }.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def query_url(url, params) = params.empty? ? url : "#{url}?#{URI.encode_www_form(params)}"
|
|
52
|
+
|
|
53
|
+
# The token of an assistant linked to the principal for this call.
|
|
54
|
+
def token_for(identity)
|
|
55
|
+
Kiosk::Server::AccountBinding.bind!(
|
|
56
|
+
public_key_pem: OpenSSL::PKey::RSA.generate(2048).public_key.to_pem,
|
|
57
|
+
user_id: identity.user_id, requested_role: identity.role
|
|
58
|
+
).fetch(:access_token)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def toll_challenges(response)
|
|
62
|
+
problem = parse(response.body)
|
|
63
|
+
return unless response.status == 402 && problem.is_a?(Hash) && problem['code'] == 'pow_required'
|
|
64
|
+
|
|
65
|
+
problem['challenges']
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def answer(response)
|
|
69
|
+
body = parse(response.body)
|
|
70
|
+
return body if response.successful?
|
|
71
|
+
|
|
72
|
+
raise refusal(response.status, body)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def refusal(status, body)
|
|
76
|
+
return RuntimeError.new("the origin answered #{status}: #{body.inspect}") unless problem?(body)
|
|
77
|
+
|
|
78
|
+
Kiosk::Server::Errors::WireError.new(body['detail'], code: body['code'], hint: body['hint'])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def problem?(body) = body.is_a?(Hash) && Kiosk::Server::Errors::CODES.key?(body['code'])
|
|
82
|
+
|
|
83
|
+
def parse(raw)
|
|
84
|
+
raw.to_s.empty? ? nil : JSON.parse(raw)
|
|
85
|
+
rescue JSON::ParserError
|
|
86
|
+
raw
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module TestHelpers
|
|
7
|
+
# The journey-test DSL, for RSpec `type: :kiosk_journey` groups and Minitest
|
|
8
|
+
# cases. The `as_*` scopes take a block and delegate to the executor.
|
|
9
|
+
module Journey
|
|
10
|
+
# @param user [#id, #role] the principal; `user.id` becomes `current_user_id`
|
|
11
|
+
# @param role [String, Symbol, nil] defaults to `user.role`, else the first configured role
|
|
12
|
+
def as_agent_of(user, role: nil, &)
|
|
13
|
+
identity = Kiosk::Identity.new(
|
|
14
|
+
user_id: user_id_of(user),
|
|
15
|
+
role: resolve_role(user, role),
|
|
16
|
+
actor: 'agent',
|
|
17
|
+
agent_id: SecureRandom.uuid
|
|
18
|
+
)
|
|
19
|
+
scope_to(identity, &)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def as_user(user, role: nil, &)
|
|
23
|
+
identity = Kiosk::Identity.new(
|
|
24
|
+
user_id: user_id_of(user),
|
|
25
|
+
role: resolve_role(user, role),
|
|
26
|
+
actor: 'human'
|
|
27
|
+
)
|
|
28
|
+
scope_to(identity, &)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# An agent for a synthetic user named `name`, when no `users` row exists.
|
|
32
|
+
def as_agent(name, role: nil, &)
|
|
33
|
+
identity = Kiosk::Identity.new(
|
|
34
|
+
user_id: "synthetic:#{name}",
|
|
35
|
+
role: role&.to_s || default_role,
|
|
36
|
+
actor: 'agent',
|
|
37
|
+
agent_id: SecureRandom.uuid
|
|
38
|
+
)
|
|
39
|
+
scope_to(identity, &)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def as_anonymous(&)
|
|
43
|
+
scope_to(nil, &)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Raw SQL; a query verb is {#run_query}.
|
|
47
|
+
def query(sql)
|
|
48
|
+
TestHelpers.require_executor!.query(sql)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run_query(name, **args)
|
|
52
|
+
TestHelpers.require_executor!.run_query(name, args)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def run_action(name, **args)
|
|
56
|
+
TestHelpers.require_executor!.run_action(name, args)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# `Kiosk::TestHelpers::InProcessExecutor` raises `NotImplementedError` here.
|
|
60
|
+
def pay_action(name, **args)
|
|
61
|
+
TestHelpers.require_executor!.pay_action(name, args)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Runs as `system_role`, so it can seed RLS tables; `owner:` sets `user_id`.
|
|
65
|
+
def kiosk_seed(table, count: 1, owner: nil, **attrs)
|
|
66
|
+
attrs = attrs.merge(user_id: user_id_of(owner)) if owner
|
|
67
|
+
TestHelpers.require_executor!.seed(table, attrs, count: count)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def scope_to(identity, &block)
|
|
73
|
+
raise ArgumentError, 'block required' unless block
|
|
74
|
+
|
|
75
|
+
TestHelpers.require_executor!.with_identity(identity, &block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def user_id_of(user)
|
|
79
|
+
user.respond_to?(:id) ? user.id : user
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_role(user, explicit)
|
|
83
|
+
return explicit.to_s if explicit
|
|
84
|
+
|
|
85
|
+
if user.respond_to?(:role) && user.role
|
|
86
|
+
user.role.to_s
|
|
87
|
+
else
|
|
88
|
+
default_role
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def default_role
|
|
93
|
+
roles = Kiosk.configuration.roles
|
|
94
|
+
roles.first.to_s if roles && !roles.empty?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'kiosk'
|
|
8
|
+
require 'kiosk/test_helpers/wire'
|
|
9
|
+
|
|
10
|
+
module Kiosk
|
|
11
|
+
module TestHelpers
|
|
12
|
+
# Stands in for the operator's KYC provider for the length of each test, so
|
|
13
|
+
# the test decides how an identity check ends.
|
|
14
|
+
#
|
|
15
|
+
# include Kiosk::TestHelpers::Kyc
|
|
16
|
+
#
|
|
17
|
+
# rider.requests_verification
|
|
18
|
+
# the_verification_service_confirms(rider, age_over_18: true)
|
|
19
|
+
module Kyc
|
|
20
|
+
ISSUER = 'https://kyc.test.invalid'
|
|
21
|
+
SETTINGS = %i[kyc_provider kyc_issuer kyc_public_key].freeze
|
|
22
|
+
|
|
23
|
+
# Opens checks without a network and signs attestations for them.
|
|
24
|
+
class Provider < Kiosk::KycProviders::Base
|
|
25
|
+
Check = Data.define(:subject, :audience, :nonce, :callback_url)
|
|
26
|
+
|
|
27
|
+
def self.key = @key ||= OpenSSL::PKey::RSA.generate(2048)
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
super
|
|
31
|
+
@checks = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def open_verification(subject:, audience:, callback_url:, **)
|
|
35
|
+
request_id = SecureRandom.uuid
|
|
36
|
+
@checks[request_id] = Check.new(subject:, audience:, nonce: SecureRandom.hex(16), callback_url:)
|
|
37
|
+
{ request_id:, nonce: @checks[request_id].nonce, verification_url: "#{ISSUER}/verify?request=#{request_id}" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def latest_check_for(subject)
|
|
41
|
+
@checks.select { |_, check| check.subject == subject.to_s }.keys.last or
|
|
42
|
+
raise ArgumentError, "no check was opened for #{subject.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check(request_id) = @checks.fetch(request_id)
|
|
46
|
+
|
|
47
|
+
def attest(subject, audience, attributes)
|
|
48
|
+
now = Time.now.to_i
|
|
49
|
+
JWT.encode({ sub: subject.to_s, level: 'verified', iss: ISSUER, aud: audience.to_s, attributes:,
|
|
50
|
+
iat: now, exp: now + 3600 }, self.class.key, 'RS256')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.included(base)
|
|
55
|
+
before, after = base.respond_to?(:setup) ? %i[setup teardown] : %i[before after]
|
|
56
|
+
base.public_send(before) { stand_in_for_kyc_provider }
|
|
57
|
+
base.public_send(after) { restore_kyc_provider }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The person behind `customer` passed the check they asked for last, with
|
|
61
|
+
# these attributes; the provider reports so through the callback the engine gave it.
|
|
62
|
+
def the_verification_service_confirms(customer, **attributes)
|
|
63
|
+
request_id = @kyc_provider.latest_check_for(customer.principal.user_id)
|
|
64
|
+
check = @kyc_provider.check(request_id)
|
|
65
|
+
jws = @kyc_provider.attest(check.subject, check.audience, attributes)
|
|
66
|
+
answer = deliver_callback(check, { request_id:, nonce: check.nonce, kyc_jws: jws })
|
|
67
|
+
raise "the engine refused the KYC callback: #{answer.status} #{answer.body}" unless answer.status == 200
|
|
68
|
+
|
|
69
|
+
jws
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# An attestation the provider signed for this principal, as an assistant
|
|
73
|
+
# submits it to `POST <endpoint>/agents/kyc`.
|
|
74
|
+
def kyc_attestation(principal, **attributes)
|
|
75
|
+
@kyc_provider.attest(principal.user_id, Kiosk.configuration.kyc_audience, attributes)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def deliver_callback(check, body)
|
|
81
|
+
Wire.new(base_url: URI.join(check.callback_url, '/').to_s).post(URI(check.callback_url).path, body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def stand_in_for_kyc_provider
|
|
85
|
+
config = Kiosk.configuration
|
|
86
|
+
@replaced_kyc_settings = SETTINGS.to_h { [it, config.public_send(it)] }
|
|
87
|
+
@kyc_provider = Provider.new
|
|
88
|
+
config.kyc_provider = @kyc_provider
|
|
89
|
+
config.kyc_issuer = ISSUER
|
|
90
|
+
config.kyc_public_key = Provider.key.public_key
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def restore_kyc_provider
|
|
94
|
+
@replaced_kyc_settings&.each { |setting, value| Kiosk.configuration.public_send(:"#{setting}=", value) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'puma'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'kiosk/test_helpers/assistant'
|
|
6
|
+
|
|
7
|
+
module Kiosk
|
|
8
|
+
module TestHelpers
|
|
9
|
+
# Tests whose writes must be committed: each starts from the seeds and
|
|
10
|
+
# leaves the database empty.
|
|
11
|
+
module SeededDatabase
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.use_transactional_tests = false
|
|
14
|
+
base.setup do
|
|
15
|
+
SeededDatabase.empty
|
|
16
|
+
Rails.application.load_seed
|
|
17
|
+
end
|
|
18
|
+
base.teardown { SeededDatabase.empty }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.empty
|
|
22
|
+
connection = ActiveRecord::Base.connection
|
|
23
|
+
schemas = ActiveRecord.dump_schemas.split(',').map { connection.quote(it.strip) }.join(', ')
|
|
24
|
+
tables = connection.select_values(<<~SQL)
|
|
25
|
+
SELECT format('%I.%I', schemaname, tablename) FROM pg_tables
|
|
26
|
+
WHERE schemaname IN (#{schemas}) AND tablename NOT IN ('schema_migrations', 'ar_internal_metadata')
|
|
27
|
+
SQL
|
|
28
|
+
connection.execute("TRUNCATE #{tables.join(', ')} RESTART IDENTITY CASCADE") if tables.any?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Serves the app over HTTP from the test process, on a free port that
|
|
33
|
+
# becomes the origin's issuer, so a test drives the wire as an assistant does.
|
|
34
|
+
module LiveServer
|
|
35
|
+
def self.included(base)
|
|
36
|
+
base.include SeededDatabase
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.url
|
|
40
|
+
@url ||= begin
|
|
41
|
+
port = TCPServer.open('127.0.0.1', 0) { |socket| socket.addr[1] }
|
|
42
|
+
server = Puma::Server.new(Rails.application, nil, min_threads: 0, max_threads: 8)
|
|
43
|
+
server.add_tcp_listener('127.0.0.1', port)
|
|
44
|
+
server.run
|
|
45
|
+
Kiosk.configuration.issuer = "http://127.0.0.1:#{port}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def live_url = LiveServer.url
|
|
50
|
+
|
|
51
|
+
def assistant = @assistant ||= Assistant.new(base_url: live_url)
|
|
52
|
+
|
|
53
|
+
def register = assistant.register!
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# In-memory executor that records every call without touching Postgres; the
|
|
6
|
+
# contract `Kiosk::TestHelpers::InProcessExecutor` implements. Queue results with
|
|
7
|
+
# `enqueue_<kind>` and errors with `enqueue_error`.
|
|
8
|
+
class NullExecutor
|
|
9
|
+
Call = Data.define(:kind, :args, :identity)
|
|
10
|
+
|
|
11
|
+
attr_reader :calls
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@calls = []
|
|
15
|
+
@identity_stack = []
|
|
16
|
+
@queues = Hash.new { |h, k| h[k] = [] }
|
|
17
|
+
@errors = Hash.new { |h, k| h[k] = [] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_identity(identity)
|
|
21
|
+
@identity_stack.push(identity)
|
|
22
|
+
yield
|
|
23
|
+
ensure
|
|
24
|
+
@identity_stack.pop
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def query(sql)
|
|
28
|
+
record(:query, { sql: sql })
|
|
29
|
+
next_result_or_raise(:query)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run_query(name, args)
|
|
33
|
+
record(:run_query, { name: name, args: args })
|
|
34
|
+
next_result_or_raise(:run_query)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_action(name, args)
|
|
38
|
+
record(:run_action, { name: name, args: args })
|
|
39
|
+
next_result_or_raise(:run_action)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def pay_action(name, args)
|
|
43
|
+
record(:pay_action, { name: name, args: args })
|
|
44
|
+
next_result_or_raise(:pay_action)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def seed(table, attrs, count:)
|
|
48
|
+
record(:seed, { table: table, attrs: attrs, count: count })
|
|
49
|
+
next_result_or_raise(:seed)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def enqueue_query(result) = @queues[:query] << result
|
|
53
|
+
def enqueue_run_query(result) = @queues[:run_query] << result
|
|
54
|
+
def enqueue_run_action(result) = @queues[:run_action] << result
|
|
55
|
+
def enqueue_pay_action(result) = @queues[:pay_action] << result
|
|
56
|
+
def enqueue_seed(result) = @queues[:seed] << result
|
|
57
|
+
|
|
58
|
+
# `error` is :rls_denied, :quota_exceeded, or a class.
|
|
59
|
+
def enqueue_error(kind, error = :rls_denied)
|
|
60
|
+
@errors[kind] << error
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def calls_of(kind) = @calls.select { |c| c.kind == kind }
|
|
64
|
+
|
|
65
|
+
def current_identity = @identity_stack.last
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def record(kind, args)
|
|
70
|
+
@calls << Call.new(kind: kind, args: args, identity: current_identity)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def next_result_or_raise(kind)
|
|
74
|
+
if (err = @errors[kind].shift)
|
|
75
|
+
raise resolve_error(err)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@queues[kind].empty? ? default_for(kind) : @queues[kind].shift
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def default_for(kind)
|
|
82
|
+
%i[query run_query].include?(kind) ? [] : nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolve_error(err)
|
|
86
|
+
case err
|
|
87
|
+
when :rls_denied then Errors::RLSDenied
|
|
88
|
+
when :quota_exceeded then Errors::QuotaExceeded
|
|
89
|
+
when Class then err
|
|
90
|
+
else raise ArgumentError, "unknown enqueued error #{err.inspect}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|