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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +149 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +146 -0
  5. data/lib/kiosk/story_spec.rb +6 -0
  6. data/lib/kiosk/story_test.rb +16 -0
  7. data/lib/kiosk/test_helpers/assistant/events.rb +135 -0
  8. data/lib/kiosk/test_helpers/assistant.rb +151 -0
  9. data/lib/kiosk/test_helpers/conformance/checks/declared_shape.rb +91 -0
  10. data/lib/kiosk/test_helpers/conformance/checks/executes.rb +57 -0
  11. data/lib/kiosk/test_helpers/conformance/checks/helpers.rb +112 -0
  12. data/lib/kiosk/test_helpers/conformance/checks/principal_scope.rb +104 -0
  13. data/lib/kiosk/test_helpers/conformance/checks/routes.rb +94 -0
  14. data/lib/kiosk/test_helpers/conformance/checks.rb +45 -0
  15. data/lib/kiosk/test_helpers/conformance/in_process_origin.rb +121 -0
  16. data/lib/kiosk/test_helpers/conformance/minitest.rb +57 -0
  17. data/lib/kiosk/test_helpers/conformance/null_origin.rb +55 -0
  18. data/lib/kiosk/test_helpers/conformance/outcome.rb +25 -0
  19. data/lib/kiosk/test_helpers/conformance/rspec.rb +100 -0
  20. data/lib/kiosk/test_helpers/conformance/verb.rb +36 -0
  21. data/lib/kiosk/test_helpers/conformance.rb +46 -0
  22. data/lib/kiosk/test_helpers/customer.rb +154 -0
  23. data/lib/kiosk/test_helpers/descriptor_examples.rb +46 -0
  24. data/lib/kiosk/test_helpers/errors.rb +55 -0
  25. data/lib/kiosk/test_helpers/in_process_executor.rb +198 -0
  26. data/lib/kiosk/test_helpers/in_process_wire.rb +90 -0
  27. data/lib/kiosk/test_helpers/journey.rb +98 -0
  28. data/lib/kiosk/test_helpers/kyc.rb +98 -0
  29. data/lib/kiosk/test_helpers/live_server.rb +56 -0
  30. data/lib/kiosk/test_helpers/null_executor.rb +95 -0
  31. data/lib/kiosk/test_helpers/person.rb +52 -0
  32. data/lib/kiosk/test_helpers/small_equihash.rb +18 -0
  33. data/lib/kiosk/test_helpers/story.rb +36 -0
  34. data/lib/kiosk/test_helpers/stripe_mock.rb +146 -0
  35. data/lib/kiosk/test_helpers/version.rb +7 -0
  36. data/lib/kiosk/test_helpers/wire.rb +124 -0
  37. data/lib/kiosk/test_helpers.rb +28 -0
  38. 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