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,121 @@
|
|
|
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/conformance/in_process_origin"
|
|
6
|
+
# require "kiosk/test_helpers/conformance/rspec" # or .../minitest
|
|
7
|
+
# Kiosk::TestHelpers::Conformance.origin = Kiosk::TestHelpers::Conformance::InProcessOrigin.new
|
|
8
|
+
require 'securerandom'
|
|
9
|
+
|
|
10
|
+
require 'kiosk/server'
|
|
11
|
+
require 'kiosk/test_helpers/conformance'
|
|
12
|
+
require 'kiosk/test_helpers/in_process_wire'
|
|
13
|
+
|
|
14
|
+
module Kiosk
|
|
15
|
+
module TestHelpers
|
|
16
|
+
module Conformance
|
|
17
|
+
# The origin the conformance checks run against: the booted app's own
|
|
18
|
+
# registries, routes and validators. A call goes through the verb's route
|
|
19
|
+
# as an assistant's does, with a token the origin signs for the principal.
|
|
20
|
+
# Calls do not roll back; undoing writes is the test framework's job.
|
|
21
|
+
class InProcessOrigin
|
|
22
|
+
# @param app [#call] the Rack app a call goes through; defaults to `Rails.application`
|
|
23
|
+
# @param routes [ActionDispatch::Routing::RouteSet] defaults to `Rails.application.routes`
|
|
24
|
+
def initialize(app: nil, routes: nil)
|
|
25
|
+
@wire = InProcessWire.new(app: app)
|
|
26
|
+
@routes = routes
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def mount_path = Kiosk.configuration.mount_path
|
|
30
|
+
|
|
31
|
+
# Not memoised: development rebuilds the registry at `to_prepare`.
|
|
32
|
+
def verbs
|
|
33
|
+
descriptors(Kiosk::Server::Queries, :query) + descriptors(Kiosk::Server::Actions, :action)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The action a request reaches, as `{controller:, action:, kiosk_verb:}`; nil when no route answers.
|
|
37
|
+
def recognize(path, method:)
|
|
38
|
+
verb_route(path, method.to_s.upcase) ||
|
|
39
|
+
route_set.recognize_path(path, method: method.to_s.downcase.to_sym).slice(:controller, :action, :kiosk_verb)
|
|
40
|
+
rescue ActionController::RoutingError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(name, kind:, params:, as: nil)
|
|
45
|
+
@wire.call(name, kind: kind, params: params, identity: identity_for(as))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Uses the wire's own validators, so a query's reserved `limit`/`cursor` pass as they do on the wire.
|
|
49
|
+
def schema_errors(payload, schema:, verb:, kind:, slot:)
|
|
50
|
+
if slot.to_s == 'input_schema'
|
|
51
|
+
Kiosk::Server::RequestValidation.validate_arguments!(payload, input_schema: schema, verb: verb,
|
|
52
|
+
query: kind == :query)
|
|
53
|
+
else
|
|
54
|
+
Kiosk::Server::ResponseValidation.validate_payload!(payload, output_schema: schema, verb: verb,
|
|
55
|
+
kind: kind)
|
|
56
|
+
end
|
|
57
|
+
[]
|
|
58
|
+
rescue Kiosk::Server::Errors::Base => e
|
|
59
|
+
["#{slot}: #{e.message}"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A record contributes its `#id`; anything else is the principal id itself.
|
|
63
|
+
def identity_for(subject, role: nil)
|
|
64
|
+
raise ArgumentError, NO_PRINCIPAL if subject.nil?
|
|
65
|
+
|
|
66
|
+
Kiosk::Identity.new(
|
|
67
|
+
user_id: subject.respond_to?(:id) ? subject.id : subject,
|
|
68
|
+
role: resolve_role(subject, role), actor: 'agent', agent_id: SecureRandom.uuid
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
NO_PRINCIPAL =
|
|
73
|
+
'a conformance call needs a principal — pass `as:` a record or a principal id. ' \
|
|
74
|
+
'Every verb is served behind authentication, so there is no anonymous call ' \
|
|
75
|
+
'to make.'
|
|
76
|
+
private_constant :NO_PRINCIPAL
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def descriptors(registry, kind)
|
|
81
|
+
registry.catalog.map do |descriptor|
|
|
82
|
+
Verb.new(
|
|
83
|
+
name: descriptor[:name], kind: kind, reach: descriptor[:reach],
|
|
84
|
+
input_schema: descriptor[:input_schema], output_schema: descriptor[:output_schema],
|
|
85
|
+
example_params: descriptor[:example_params], endpoint: endpoint(registry.fetch(descriptor[:name]))
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The action that answers the verb: its own, or the engine's verb endpoint.
|
|
91
|
+
def endpoint(handler)
|
|
92
|
+
controller = handler.controller if handler.is_a?(Kiosk::Server::ControllerVerb)
|
|
93
|
+
"#{controller.controller_path}##{handler.method_name}" if controller
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The verb route `kiosk do` drew at `path`, as the action it reaches.
|
|
97
|
+
def verb_route(path, method)
|
|
98
|
+
route = route_set.routes.find { |r| verb_route?(r) && r.verb == method && r.path.match(path) }
|
|
99
|
+
return unless route
|
|
100
|
+
|
|
101
|
+
controller, action = route.app.app.endpoint.split('#')
|
|
102
|
+
{ controller: controller, action: action, kiosk_verb: route.app.app.name }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def verb_route?(route)
|
|
106
|
+
route.app.respond_to?(:app) && route.app.app.is_a?(Kiosk::Server::Routing::VerbRoute)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def route_set = @routes || ::Rails.application.routes
|
|
110
|
+
|
|
111
|
+
def resolve_role(subject, explicit)
|
|
112
|
+
return explicit.to_s if explicit
|
|
113
|
+
return subject.role.to_s if subject.respond_to?(:role) && subject.role
|
|
114
|
+
|
|
115
|
+
roles = Kiosk.configuration.roles
|
|
116
|
+
roles.first.to_s if roles && !roles.empty?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'minitest/assertions'
|
|
4
|
+
|
|
5
|
+
require 'kiosk/test_helpers/conformance'
|
|
6
|
+
|
|
7
|
+
module Kiosk
|
|
8
|
+
module TestHelpers
|
|
9
|
+
module Conformance
|
|
10
|
+
# Minitest assertions for the four conformance checks; required by name only.
|
|
11
|
+
# Each returns its {Outcome}.
|
|
12
|
+
module Assertions
|
|
13
|
+
def assert_kiosk_verbs_routed(message = nil, origin: nil)
|
|
14
|
+
kiosk_conformance!(Conformance.routes(kiosk_conformance_origin(origin)), message)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def assert_kiosk_verb_executes(name, params: Checks::EXAMPLE, as: nil,
|
|
18
|
+
message: nil, origin: nil)
|
|
19
|
+
kiosk_conformance!(
|
|
20
|
+
Checks.executes(kiosk_conformance_origin(origin), name, params: params, as: as),
|
|
21
|
+
message
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def assert_kiosk_answer_matches_declared_schema(name, params: Checks::EXAMPLE, as: nil,
|
|
26
|
+
message: nil, origin: nil)
|
|
27
|
+
kiosk_conformance!(
|
|
28
|
+
Checks.declared_shape(kiosk_conformance_origin(origin), name, params: params, as: as),
|
|
29
|
+
message
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# rubocop:disable-next Metrics/ParameterLists -- public assertion signature; every keyword is part of it
|
|
34
|
+
def assert_kiosk_scoped_to_principal(name, as:, and_not:, params: Checks::EXAMPLE,
|
|
35
|
+
message: nil, origin: nil)
|
|
36
|
+
kiosk_conformance!(
|
|
37
|
+
Checks.principal_scope(kiosk_conformance_origin(origin), name,
|
|
38
|
+
as: as, and_not: and_not, params: params),
|
|
39
|
+
message
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# `assert` rather than `flunk`, so a passing check is counted.
|
|
46
|
+
def kiosk_conformance!(outcome, message)
|
|
47
|
+
assert(outcome.ok?, message || outcome.message)
|
|
48
|
+
outcome
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def kiosk_conformance_origin(explicit)
|
|
52
|
+
explicit || Conformance.require_origin!
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kiosk/test_helpers/conformance/verb'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module TestHelpers
|
|
7
|
+
module Conformance
|
|
8
|
+
# A hand-built origin with no Rails, Postgres or engine; this gem's own
|
|
9
|
+
# suite runs the checks against it. The origin contract:
|
|
10
|
+
#
|
|
11
|
+
# #mount_path → String, e.g. "/kiosk"
|
|
12
|
+
# #verbs → Array<Verb>
|
|
13
|
+
# #recognize(path, method:) → {controller:, action:, kiosk_verb:} or nil
|
|
14
|
+
# #call(name, kind:, params:, as:) → the verb's answer, or raises
|
|
15
|
+
# #schema_errors(payload, schema:, verb:, kind:, slot:) → Array<String> (optional)
|
|
16
|
+
class NullOrigin
|
|
17
|
+
attr_reader :mount_path, :verbs, :calls
|
|
18
|
+
|
|
19
|
+
# @param routes [Hash] `[method, path] => {controller:, action:, kiosk_verb:}`
|
|
20
|
+
# @param answers [Hash] `[verb_name, principal]` or `verb_name` => answer; an Exception is raised
|
|
21
|
+
def initialize(mount_path: '/kiosk', verbs: [], routes: nil, answers: {})
|
|
22
|
+
@mount_path = mount_path
|
|
23
|
+
@verbs = verbs
|
|
24
|
+
@routes = routes || self.class.routes_for(mount_path, verbs)
|
|
25
|
+
@answers = answers
|
|
26
|
+
@calls = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The route table a correctly routed origin would have.
|
|
30
|
+
def self.routes_for(mount_path, verbs)
|
|
31
|
+
verbs.each_with_object({}) do |verb, table|
|
|
32
|
+
path = "#{mount_path.chomp('/')}/#{verb.name}"
|
|
33
|
+
controller, action = verb.endpoint.split('#')
|
|
34
|
+
table[[verb.http_method, path]] = {
|
|
35
|
+
controller: controller, action: action, kiosk_verb: verb.name
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def recognize(path, method:)
|
|
41
|
+
@routes[[method.to_s.upcase, path]]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(name, kind:, params:, as: nil)
|
|
45
|
+
@calls << { name: name, kind: kind, params: params, as: as }
|
|
46
|
+
answer = @answers.fetch([name, as]) { @answers.fetch(name, nil) }
|
|
47
|
+
answer = answer.call(params: params, as: as) if answer.is_a?(Proc)
|
|
48
|
+
raise answer if answer.is_a?(::Exception)
|
|
49
|
+
|
|
50
|
+
answer
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
module Conformance
|
|
6
|
+
# The result of one check. A failure is returned, never raised; `message` is
|
|
7
|
+
# the sentence both adapters render, `details` the facts behind it.
|
|
8
|
+
Outcome = Data.define(:ok, :check, :subject, :message, :details) do
|
|
9
|
+
def ok? = ok
|
|
10
|
+
|
|
11
|
+
def failed? = !ok
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.pass(check, **) = outcome(true, check, **)
|
|
15
|
+
|
|
16
|
+
def self.fail(check, **) = outcome(false, check, **)
|
|
17
|
+
|
|
18
|
+
def self.outcome(passed, check, subject: nil, message: '', details: {})
|
|
19
|
+
Outcome.new(ok: passed, check: check.to_sym, subject: subject,
|
|
20
|
+
message: message.to_s, details: details || {})
|
|
21
|
+
end
|
|
22
|
+
private_class_method :outcome
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/expectations'
|
|
4
|
+
|
|
5
|
+
require 'kiosk/test_helpers/conformance'
|
|
6
|
+
|
|
7
|
+
module Kiosk
|
|
8
|
+
module TestHelpers
|
|
9
|
+
module Conformance
|
|
10
|
+
# RSpec matchers for the four conformance checks; required by name only.
|
|
11
|
+
module RSpecHelpers
|
|
12
|
+
def kiosk_origin = Conformance.require_origin!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The matcher DSL hands keywords to a block as one positional Hash.
|
|
16
|
+
module RSpecOptions
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def origin(options)
|
|
20
|
+
options[:origin] || Conformance.require_origin!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def params(options)
|
|
24
|
+
options.fetch(:params, Checks::EXAMPLE)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# `expect(kiosk_origin).to have_a_route_for_every_verb`
|
|
32
|
+
# Without `|options = {}|` a keyword `origin:` would be silently dropped.
|
|
33
|
+
RSpec::Matchers.define :have_a_route_for_every_verb do |options = {}|
|
|
34
|
+
match do |origin|
|
|
35
|
+
@outcome = Kiosk::TestHelpers::Conformance::Checks.routes(
|
|
36
|
+
origin || Kiosk::TestHelpers::Conformance::RSpecOptions.origin(options)
|
|
37
|
+
)
|
|
38
|
+
@outcome.ok?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
failure_message { @outcome.message }
|
|
42
|
+
failure_message_when_negated { "expected some verb to be unrouted, but #{@outcome.message}" }
|
|
43
|
+
description { 'have a route for every declared verb' }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# `expect(:catalog).to execute_as_a_kiosk_verb`
|
|
47
|
+
RSpec::Matchers.define :execute_as_a_kiosk_verb do |options = {}|
|
|
48
|
+
match do |name|
|
|
49
|
+
@outcome = Kiosk::TestHelpers::Conformance::Checks.executes(
|
|
50
|
+
Kiosk::TestHelpers::Conformance::RSpecOptions.origin(options), name,
|
|
51
|
+
params: Kiosk::TestHelpers::Conformance::RSpecOptions.params(options),
|
|
52
|
+
as: options[:as]
|
|
53
|
+
)
|
|
54
|
+
@outcome.ok?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
failure_message { @outcome.message }
|
|
58
|
+
failure_message_when_negated { "expected #{@outcome.subject} to refuse, but #{@outcome.message}" }
|
|
59
|
+
description { 'execute as a Kiosk verb' }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `expect(:catalog).to answer_its_declared_schema`
|
|
63
|
+
RSpec::Matchers.define :answer_its_declared_schema do |options = {}|
|
|
64
|
+
match do |name|
|
|
65
|
+
@outcome = Kiosk::TestHelpers::Conformance::Checks.declared_shape(
|
|
66
|
+
Kiosk::TestHelpers::Conformance::RSpecOptions.origin(options), name,
|
|
67
|
+
params: Kiosk::TestHelpers::Conformance::RSpecOptions.params(options),
|
|
68
|
+
as: options[:as]
|
|
69
|
+
)
|
|
70
|
+
@outcome.ok?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
failure_message { @outcome.message }
|
|
74
|
+
failure_message_when_negated do
|
|
75
|
+
"expected #{@outcome.subject} to answer something its own schema rejects, " \
|
|
76
|
+
"but #{@outcome.message}"
|
|
77
|
+
end
|
|
78
|
+
description { 'answer the shape its own output_schema declares' }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# `expect(:my_orders).to be_scoped_to_principal(as: alice, and_not: bob)`
|
|
82
|
+
RSpec::Matchers.define :be_scoped_to_principal do |options = {}|
|
|
83
|
+
match do |name|
|
|
84
|
+
@outcome = Kiosk::TestHelpers::Conformance::Checks.principal_scope(
|
|
85
|
+
Kiosk::TestHelpers::Conformance::RSpecOptions.origin(options), name,
|
|
86
|
+
as: options.fetch(:as),
|
|
87
|
+
and_not: options.fetch(:and_not),
|
|
88
|
+
params: Kiosk::TestHelpers::Conformance::RSpecOptions.params(options)
|
|
89
|
+
)
|
|
90
|
+
@outcome.ok?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
failure_message { @outcome.message }
|
|
94
|
+
failure_message_when_negated do
|
|
95
|
+
"expected #{@outcome.subject} to leak across principals, but #{@outcome.message}"
|
|
96
|
+
end
|
|
97
|
+
description { 'be scoped to the calling principal' }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
RSpec.configure { |config| config.include(Kiosk::TestHelpers::Conformance::RSpecHelpers) } if defined?(RSpec) && RSpec.respond_to?(:configure)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
module Conformance
|
|
6
|
+
HTTP_METHODS = { query: 'GET', action: 'POST' }.freeze
|
|
7
|
+
|
|
8
|
+
# The engine's own endpoints, which answer the verbs the engine serves.
|
|
9
|
+
WIRE_ENDPOINTS = {
|
|
10
|
+
query: 'kiosk/server/verb#show',
|
|
11
|
+
action: 'kiosk/server/verb#create'
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# One declared verb, as an origin's descriptor presents it to the checks.
|
|
15
|
+
# `endpoint` is the `controller#action` that answers it.
|
|
16
|
+
Verb = Data.define(:name, :kind, :reach, :input_schema, :output_schema, :example_params, :endpoint) do
|
|
17
|
+
def initialize(name:, kind:, reach: 'principal', endpoint: nil, **schemas)
|
|
18
|
+
kind = kind.to_sym
|
|
19
|
+
raise ArgumentError, "kind must be :query or :action, got #{kind.inspect}" unless HTTP_METHODS.key?(kind)
|
|
20
|
+
|
|
21
|
+
super(name: name.to_s, kind: kind, reach: (reach || 'principal').to_s,
|
|
22
|
+
endpoint: endpoint || WIRE_ENDPOINTS.fetch(kind),
|
|
23
|
+
input_schema: nil, output_schema: nil, example_params: nil, **schemas)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def query? = kind == :query
|
|
27
|
+
|
|
28
|
+
def http_method = HTTP_METHODS.fetch(kind)
|
|
29
|
+
|
|
30
|
+
def other_http_method = HTTP_METHODS.fetch(query? ? :action : :query)
|
|
31
|
+
|
|
32
|
+
def to_s = "#{kind} #{name.inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kiosk/test_helpers/errors'
|
|
4
|
+
require 'kiosk/test_helpers/conformance/outcome'
|
|
5
|
+
require 'kiosk/test_helpers/conformance/verb'
|
|
6
|
+
require 'kiosk/test_helpers/conformance/checks'
|
|
7
|
+
require 'kiosk/test_helpers/conformance/null_origin'
|
|
8
|
+
|
|
9
|
+
module Kiosk
|
|
10
|
+
module TestHelpers
|
|
11
|
+
# Four checks an origin runs against itself: routes resolve, a verb executes,
|
|
12
|
+
# an answer matches its declared schema, data access is scoped to the principal.
|
|
13
|
+
# Set `Conformance.origin`, then require the minitest or rspec adapter.
|
|
14
|
+
module Conformance
|
|
15
|
+
class << self
|
|
16
|
+
# Anything answering the {NullOrigin} contract.
|
|
17
|
+
attr_accessor :origin
|
|
18
|
+
|
|
19
|
+
def require_origin!
|
|
20
|
+
origin || raise(Errors::OriginNotConfigured)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset!
|
|
24
|
+
@origin = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The four {Checks} against the configured origin, returning an {Outcome}.
|
|
28
|
+
def routes(origin = require_origin!)
|
|
29
|
+
Checks.routes(origin)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def executes(name, params: Checks::EXAMPLE, as: nil, origin: require_origin!)
|
|
33
|
+
Checks.executes(origin, name, params: params, as: as)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def declared_shape(name, params: Checks::EXAMPLE, as: nil, origin: require_origin!)
|
|
37
|
+
Checks.declared_shape(origin, name, params: params, as: as)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def principal_scope(name, as:, and_not:, params: Checks::EXAMPLE, origin: require_origin!)
|
|
41
|
+
Checks.principal_scope(origin, name, as: as, and_not: and_not, params: params)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'jwt'
|
|
5
|
+
require 'kiosk/test_helpers/assistant'
|
|
6
|
+
|
|
7
|
+
module Kiosk
|
|
8
|
+
module TestHelpers
|
|
9
|
+
# What the origin answered: what was asked for, or a refusal with its code
|
|
10
|
+
# and a hint about what to do instead.
|
|
11
|
+
class Answer
|
|
12
|
+
def initialize(response) = @response = response
|
|
13
|
+
|
|
14
|
+
def ok? = @response.status.between?(200, 299)
|
|
15
|
+
|
|
16
|
+
# A problem document's `code`, or an OAuth `error` such as `authorization_pending`.
|
|
17
|
+
def refused?(code) = !ok? && [@response.body['code'], @response.body['error']].include?(code.to_s)
|
|
18
|
+
|
|
19
|
+
def hint = @response.body['hint'].to_s
|
|
20
|
+
def detail = @response.body['detail'].to_s
|
|
21
|
+
def [](key) = @response.body[key.to_s]
|
|
22
|
+
def rows = @response.body
|
|
23
|
+
def header(name) = @response[name]
|
|
24
|
+
|
|
25
|
+
# How many proofs of work the assistant solved to be answered.
|
|
26
|
+
def tolls_paid = @response.proofs
|
|
27
|
+
|
|
28
|
+
# The proofs of work that pay the toll this refusal demands.
|
|
29
|
+
def solved_toll = Wire.solve(self['challenges'])
|
|
30
|
+
|
|
31
|
+
# The `Link` target with `rel="next"`: the rest of a truncated list.
|
|
32
|
+
def next_page = header('link').to_s[/<([^>]*)>\s*;\s*rel="next"/, 1]
|
|
33
|
+
|
|
34
|
+
def to_s = "#{@response.status} #{@response.body}"
|
|
35
|
+
alias inspect to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Someone's AI assistant at this origin. A demo subclasses it to name what
|
|
39
|
+
# its customers do there: a shopper orders and pays, a rider reserves and rides.
|
|
40
|
+
class Customer
|
|
41
|
+
attr_reader :principal
|
|
42
|
+
|
|
43
|
+
# An assistant holding a key and no account at its origin yet.
|
|
44
|
+
def self.newcomer(assistant)
|
|
45
|
+
new(assistant, Assistant::Principal.new(agent_id: nil, user_id: nil, token: nil, rsa_key: OpenSSL::PKey::RSA.generate(2048)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(assistant, principal)
|
|
49
|
+
@assistant = assistant
|
|
50
|
+
@principal = principal
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def origin = @assistant.base_url
|
|
54
|
+
|
|
55
|
+
# Pays any proof-of-work toll asked for, unless `unpaid:` or carrying its own `proofs:`.
|
|
56
|
+
def asks(query, headers: {}, unpaid: false, proofs: nil, **params)
|
|
57
|
+
headers = headers.merge('Kiosk-PoW' => JSON.generate(proofs)) if proofs
|
|
58
|
+
Answer.new(@assistant.query(principal, name: query.to_s, headers:, pay_tolls: !(unpaid || proofs), **params))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def does(action, headers: {}, **args) = Answer.new(@assistant.run(principal, name: action.to_s, headers:, **args))
|
|
62
|
+
|
|
63
|
+
# Signs intent, cart and payment mandates for `total` cents and pays.
|
|
64
|
+
def pays(total:, scope:, line_items:, currency: 'eur')
|
|
65
|
+
Answer.new(@assistant.pay(principal, **@assistant.mandates(principal, total:, scope:, line_items:, currency:)))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Signs the `cart` an action answered with, unchanged, and pays.
|
|
69
|
+
def pays_cart(cart, scope:, currency: cart['currency'])
|
|
70
|
+
pays(total: cart['total_amount_cents'], scope:, line_items: cart['line_items'], currency:)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The answer says whether a card is on file, or carries the page where the person saves one.
|
|
74
|
+
def sets_up_payment = does(:payment_setup)
|
|
75
|
+
|
|
76
|
+
# Listens for the check's outcome, then asks for it; the answer carries the page
|
|
77
|
+
# the assistant hands to its person.
|
|
78
|
+
def requests_verification
|
|
79
|
+
listens_for(:kyc_verification)
|
|
80
|
+
does(:request_kyc).tap { @verification = it['request_id'] if it.ok? }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Whether the origin reports the verification this customer asked for last passed.
|
|
84
|
+
# rubocop:disable-next Naming/PredicateMethod -- public journey DSL name the demo stories call
|
|
85
|
+
def hears_verification_passed = hears(:kyc_verification, about: @verification)['status'] == 'approved'
|
|
86
|
+
|
|
87
|
+
# Hands the origin an identity attestation the person obtained elsewhere.
|
|
88
|
+
def presents(attestation) = Answer.new(@assistant.kyc(principal, attestation_jws: attestation))
|
|
89
|
+
|
|
90
|
+
# What the origin signed into the credential: whose account, in which role.
|
|
91
|
+
def claims = JWT.decode(principal.token, nil, false).first
|
|
92
|
+
def account = claims['sub']
|
|
93
|
+
def role = claims['role']
|
|
94
|
+
|
|
95
|
+
def signs_back_in = Answer.new(@assistant.login(principal.rsa_key))
|
|
96
|
+
|
|
97
|
+
# This assistant holding the credential a fresh sign-in issues.
|
|
98
|
+
def with_a_fresh_credential = holding(signs_back_in)
|
|
99
|
+
|
|
100
|
+
# Redeems the link code a person handed over; the assistant then acts for that person.
|
|
101
|
+
def redeems(link_code) = holding(Answer.new(@assistant.claim(link_code, principal.rsa_key)))
|
|
102
|
+
|
|
103
|
+
# Asks to be linked to a person's account; the answer carries the code the person approves.
|
|
104
|
+
def asks_to_be_linked(client_id: 'kiosk-story')
|
|
105
|
+
Answer.new(@assistant.device_authorization(client_id:, public_key: principal.rsa_key.public_key.to_pem))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Asks once whether the person has approved `request`.
|
|
109
|
+
def polls(request) = Answer.new(@assistant.device_token(request['device_code'], principal.rsa_key))
|
|
110
|
+
|
|
111
|
+
# This assistant holding the credential the person granted through `request`.
|
|
112
|
+
def collects(request) = holding(polls(request))
|
|
113
|
+
|
|
114
|
+
# News the origin publishes on `topic` from now on reaches the assistant.
|
|
115
|
+
def listens_for(topic)
|
|
116
|
+
@news ||= @assistant.events(principal)
|
|
117
|
+
@news.subscribe(topic.to_s)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A connection of its own to the news on `topics`, about `subject`, from after event `since`.
|
|
121
|
+
def follows(*topics, subject: nil, since: nil)
|
|
122
|
+
@assistant.events(principal).tap do |news|
|
|
123
|
+
topics.each { news.subscribe(it.to_s, **{ subject:, since: }.compact) }
|
|
124
|
+
(@followed ||= []) << news
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# What the origin told the assistant on `topic` (about `subject`, carrying `data`),
|
|
129
|
+
# in the shape the origin publishes for that topic.
|
|
130
|
+
def hears(topic, about: nil, on: @news, **data)
|
|
131
|
+
event = on.await { heard?(it, topic, about, data) }
|
|
132
|
+
errors = Assistant::Events.payload_errors(@assistant.schema, [event])
|
|
133
|
+
raise Assistant::Events::Error, "#{origin} published #{event} off its schema: #{errors.join('; ')}" if errors.any?
|
|
134
|
+
|
|
135
|
+
event['data']
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def heard?(event, topic, about, data)
|
|
141
|
+
event['topic'] == topic.to_s && (about.nil? || event['subject'] == about.to_s) &&
|
|
142
|
+
data.all? { |key, value| event.dig('data', key.to_s) == value }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def holding(answer)
|
|
146
|
+
raise "#{origin} issued no credential: #{answer}" unless answer.ok?
|
|
147
|
+
|
|
148
|
+
token = answer['access_token']
|
|
149
|
+
held = JWT.decode(token, nil, false).first
|
|
150
|
+
self.class.new(@assistant, principal.with(agent_id: held['agent_id'], user_id: held['sub'], token:))
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json_schemer'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module TestHelpers
|
|
7
|
+
# The examples a served /kiosk/schema publishes, each beside the schema it
|
|
8
|
+
# must satisfy: `example_params` against `input_schema`, `example_row`
|
|
9
|
+
# against one row of `output_schema`.
|
|
10
|
+
module DescriptorExamples
|
|
11
|
+
PAGING = %w[limit cursor].freeze
|
|
12
|
+
|
|
13
|
+
Example = Data.define(:verb, :slot, :value, :schema) do
|
|
14
|
+
def violation
|
|
15
|
+
return "#{verb}: #{slot} has no schema to satisfy" unless schema
|
|
16
|
+
|
|
17
|
+
errors = JSONSchemer.schema(schema, meta_schema: 'https://json-schema.org/draft/2020-12/schema')
|
|
18
|
+
.validate(value).first(3).map { it['error'] }
|
|
19
|
+
"#{verb}: #{slot} violates its schema — #{errors.join('; ')}" if errors.any?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.of(document)
|
|
24
|
+
(Array(document['queries']) + Array(document['actions'])).flat_map do |descriptor|
|
|
25
|
+
[params(descriptor), row(descriptor)].compact
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.params(descriptor)
|
|
30
|
+
return unless descriptor.key?('example_params')
|
|
31
|
+
|
|
32
|
+
declared = descriptor['input_schema']
|
|
33
|
+
undeclared = PAGING - (declared&.dig('properties')&.keys || [])
|
|
34
|
+
Example.new(descriptor['name'], 'example_params', descriptor['example_params'].except(*undeclared), declared)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.row(descriptor)
|
|
38
|
+
return unless descriptor.key?('example_row')
|
|
39
|
+
|
|
40
|
+
declared = descriptor['output_schema']
|
|
41
|
+
declared = declared.except('type', 'description', 'items').merge(declared['items']) if declared&.dig('type') == 'array' && declared['items'].is_a?(Hash)
|
|
42
|
+
Example.new(descriptor['name'], 'example_row', descriptor['example_row'], declared)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|