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,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'jwt'
|
|
8
|
+
require 'kiosk/test_helpers/wire'
|
|
9
|
+
require 'kiosk/test_helpers/assistant/events'
|
|
10
|
+
|
|
11
|
+
module Kiosk
|
|
12
|
+
module TestHelpers
|
|
13
|
+
# An assistant on the wire: registers, then queries, runs and pays as the
|
|
14
|
+
# principal it registered, paying every proof-of-work toll it is asked for.
|
|
15
|
+
#
|
|
16
|
+
# assistant = Kiosk::TestHelpers::Assistant.new(base_url: live_url)
|
|
17
|
+
# rider = assistant.register!
|
|
18
|
+
# assistant.run(rider, name: "reserve", scooter_code: "SK-001")
|
|
19
|
+
class Assistant
|
|
20
|
+
class RegistrationError < StandardError; end
|
|
21
|
+
|
|
22
|
+
# The account an assistant registered, and the key that signs its mandates.
|
|
23
|
+
Principal = Data.define(:agent_id, :user_id, :token, :rsa_key)
|
|
24
|
+
|
|
25
|
+
attr_reader :wire
|
|
26
|
+
|
|
27
|
+
def initialize(base_url:)
|
|
28
|
+
@wire = Wire.new(base_url:, pay_tolls: true)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def base_url = wire.base_url
|
|
32
|
+
|
|
33
|
+
# `pow:` is `:solve`, `:skip` (send no proof) or a String sent verbatim as
|
|
34
|
+
# the proof; `wire_role:` puts a `role` the engine must ignore into the body.
|
|
35
|
+
def register_raw(pow: :solve, wire_role: nil)
|
|
36
|
+
register(pow:, wire_role:).first
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def register!(pow: :solve)
|
|
40
|
+
response, key = register(pow:)
|
|
41
|
+
raise RegistrationError, "register! expected 201, got #{response.status}: #{response.body.inspect}" unless response.status == 201
|
|
42
|
+
|
|
43
|
+
Principal.new(agent_id: response.body.fetch('agent_id'), user_id: response.body.fetch('user_id'),
|
|
44
|
+
token: response.body.fetch('access_token'), rsa_key: key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The unauthenticated request that opens the account-binding ceremony (RFC 8628 §3.1).
|
|
48
|
+
def device_authorization(client_id:, public_key:, **extra)
|
|
49
|
+
wire.post_form('/kiosk/oauth/device_authorization',
|
|
50
|
+
{ 'client_id' => client_id, 'public_key' => public_key }.merge(extra.transform_keys(&:to_s)))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def kyc(principal, attestation_jws:)
|
|
54
|
+
wire.post('/kiosk/agents/kyc', { kyc_jws: attestation_jws }, wire.bearer(principal.token))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# `pay_tolls: false` answers a proof-of-work toll instead of paying it.
|
|
58
|
+
def query(principal, name:, headers: {}, pay_tolls: true, **params)
|
|
59
|
+
(pay_tolls ? wire : unpaid).get("/kiosk/#{name}", params, wire.bearer(principal.token).merge(headers))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run(principal, name:, headers: {}, **args)
|
|
63
|
+
wire.post("/kiosk/#{name}", args, wire.bearer(principal.token).merge(headers))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The intent and cart mandates for a quote of `total` cents, for #pay.
|
|
67
|
+
def mandates(principal, total:, scope:, line_items:, currency: 'eur')
|
|
68
|
+
now = Time.now.to_i
|
|
69
|
+
common = { user_id: principal.user_id, agent_id: principal.agent_id, iss: base_url, currency:, iat: now, exp: now + 600 }
|
|
70
|
+
intent = common.merge(id: SecureRandom.uuid, scope:, cap_amount_cents: total)
|
|
71
|
+
{ intent:, cart: common.merge(id: SecureRandom.uuid, intent_mandate_id: intent[:id], total_amount_cents: total, line_items:) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def pay(principal, intent:, cart:, payment_method: 'pm_demo')
|
|
75
|
+
payment = payment_mandate(principal, cart:, payment_method:)
|
|
76
|
+
pay_raw(principal, intent_jws: sign_mandate(principal, intent), cart_jws: sign_mandate(principal, cart),
|
|
77
|
+
payment_jws: sign_mandate(principal, payment))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# On a 402 `payment_setup_required` it calls `payment_setup`, as the hint
|
|
81
|
+
# asks, and sends the same mandates once more. Against stripe-mock the
|
|
82
|
+
# Customer that `payment_setup` maps already lists a card, so that answer settles.
|
|
83
|
+
def pay_raw(principal, intent_jws:, cart_jws:, payment_jws:)
|
|
84
|
+
body = { intent_mandate_jws: intent_jws, cart_mandate_jws: cart_jws, payment_mandate_jws: payment_jws }
|
|
85
|
+
answer = wire.post('/kiosk/pay', body, wire.bearer(principal.token))
|
|
86
|
+
return answer unless setup_required?(answer)
|
|
87
|
+
|
|
88
|
+
run(principal, name: 'payment_setup')
|
|
89
|
+
wire.post('/kiosk/pay', body, wire.bearer(principal.token))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def payment_mandate(principal, cart:, payment_method: 'pm_demo')
|
|
93
|
+
cart = cart.transform_keys(&:to_sym)
|
|
94
|
+
now = Time.now.to_i
|
|
95
|
+
{ id: SecureRandom.uuid, cart_mandate_id: cart[:id], user_id: principal.user_id, agent_id: principal.agent_id,
|
|
96
|
+
iss: cart[:iss], payment_method:, amount_cents: cart[:total_amount_cents], currency: cart[:currency],
|
|
97
|
+
exp: now + 600, iat: now }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def sign_mandate(principal, payload) = JWT.encode(payload, principal.rsa_key, 'RS256')
|
|
101
|
+
|
|
102
|
+
# Signs in again with the key it holds.
|
|
103
|
+
def login(key) = wire.post('/kiosk/auth/login', possession(key))
|
|
104
|
+
|
|
105
|
+
# Redeems a link code a person minted on the operator's site.
|
|
106
|
+
def claim(code, key) = wire.post('/kiosk/auth/claim', { code: }.merge(possession(key)))
|
|
107
|
+
|
|
108
|
+
# Polls for the credential a person grants on the operator's site (RFC 8628 §3.4).
|
|
109
|
+
def device_token(device_code, key)
|
|
110
|
+
wire.post_form('/kiosk/oauth/token', 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code',
|
|
111
|
+
'device_code' => device_code, 'signed' => proof_of_possession(key))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A proof that the assistant holds `key`, over a fresh challenge and bound to this origin.
|
|
115
|
+
def proof_of_possession(key)
|
|
116
|
+
_, challenge = wire.get_json('/kiosk/auth/challenge', public_key: key.public_key.to_pem)
|
|
117
|
+
JWT.encode({ aud: base_url, nonce: challenge['challenge'], jti: SecureRandom.uuid, iat: Time.now.to_i }, key, 'RS256')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# What the origin publishes about itself at `/kiosk/schema`.
|
|
121
|
+
def schema = @schema ||= wire.get_json('/kiosk/schema').last
|
|
122
|
+
|
|
123
|
+
def events(principal) = Events.new(base_url:, token: principal.token).tap { (@connections ||= []) << it }
|
|
124
|
+
|
|
125
|
+
# Closes every event connection it opened.
|
|
126
|
+
def disconnect = @connections&.each(&:close)
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def register(pow:, wire_role: nil)
|
|
131
|
+
key = OpenSSL::PKey::RSA.generate(2048)
|
|
132
|
+
body = possession(key)
|
|
133
|
+
body[:role] = wire_role if wire_role
|
|
134
|
+
response = case pow
|
|
135
|
+
when :solve then wire.post('/kiosk/auth/register', body)
|
|
136
|
+
when :skip then unpaid.post('/kiosk/auth/register', body)
|
|
137
|
+
else unpaid.post('/kiosk/auth/register', body, 'Kiosk-PoW' => pow)
|
|
138
|
+
end
|
|
139
|
+
[response, key]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def possession(key) = { public_key: key.public_key.to_pem, signed: proof_of_possession(key) }
|
|
143
|
+
|
|
144
|
+
def unpaid = Wire.new(base_url:)
|
|
145
|
+
|
|
146
|
+
def setup_required?(answer)
|
|
147
|
+
answer.status == 402 && answer.body.is_a?(Hash) && answer.body['code'] == 'payment_setup_required'
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
module Conformance
|
|
6
|
+
module Checks
|
|
7
|
+
# The arguments satisfy the verb's input_schema and its answer satisfies
|
|
8
|
+
# its output_schema; the two failures are reported separately.
|
|
9
|
+
module DeclaredShape
|
|
10
|
+
extend Helpers
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(origin, name, params:, as:)
|
|
15
|
+
verb = find_verb(origin, name)
|
|
16
|
+
return verb if verb.is_a?(Outcome)
|
|
17
|
+
|
|
18
|
+
arguments = arguments_for(verb, params)
|
|
19
|
+
missing_schema(verb) ||
|
|
20
|
+
input_rejected(origin, verb, params, arguments) ||
|
|
21
|
+
answer_checked(origin, verb, params, as)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def missing_schema(verb)
|
|
25
|
+
return no_input_schema(verb) if verb.input_schema.nil?
|
|
26
|
+
|
|
27
|
+
no_output_schema(verb) if verb.output_schema.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def no_input_schema(verb)
|
|
31
|
+
Conformance.fail(
|
|
32
|
+
:declared_shape, subject: verb.name,
|
|
33
|
+
message: "#{verb} publishes no input_schema. Both schemas are required of every " \
|
|
34
|
+
'verb; declare one (a verb that takes nothing declares the closed empty ' \
|
|
35
|
+
'object) rather than leaving callers to guess.'
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def no_output_schema(verb)
|
|
40
|
+
Conformance.fail(
|
|
41
|
+
:declared_shape, subject: verb.name,
|
|
42
|
+
message: "#{verb} publishes no output_schema, so nothing states the shape of its " \
|
|
43
|
+
'answer. Declare one — with no response envelope it is the only ' \
|
|
44
|
+
'machine-readable statement a caller has.'
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def input_rejected(origin, verb, params, arguments)
|
|
49
|
+
errors = schema_errors(origin, arguments, verb.input_schema, verb, 'input_schema')
|
|
50
|
+
return if errors.empty?
|
|
51
|
+
|
|
52
|
+
Conformance.fail(
|
|
53
|
+
:declared_shape, subject: verb.name,
|
|
54
|
+
message: "the arguments#{params_provenance(verb, params)} do not satisfy #{verb}'s " \
|
|
55
|
+
"own input_schema: #{errors.join('; ')}. Fix whichever is wrong — a " \
|
|
56
|
+
'published example an assistant cannot copy is worse than none.',
|
|
57
|
+
details: { verb: verb.name, stage: :input, errors: errors, params: arguments }
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def answer_checked(origin, verb, params, as)
|
|
62
|
+
executed = Checks.executes(origin, verb.name, params: params, as: as)
|
|
63
|
+
return executed if executed.failed?
|
|
64
|
+
|
|
65
|
+
answer = executed.details[:answer]
|
|
66
|
+
errors = schema_errors(origin, answer, verb.output_schema, verb, 'output_schema')
|
|
67
|
+
errors.empty? ? answer_accepted(verb, answer) : answer_rejected(verb, answer, errors)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def answer_accepted(verb, answer)
|
|
71
|
+
Conformance.pass(
|
|
72
|
+
:declared_shape, subject: verb.name,
|
|
73
|
+
message: "#{verb} answered a payload its own output_schema accepts",
|
|
74
|
+
details: { verb: verb.name, answer: answer }
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def answer_rejected(verb, answer, errors)
|
|
79
|
+
Conformance.fail(
|
|
80
|
+
:declared_shape, subject: verb.name,
|
|
81
|
+
message: "#{verb} rendered a payload its own output_schema rejects: " \
|
|
82
|
+
"#{errors.join('; ')}. The descriptor and the handler disagree; fix " \
|
|
83
|
+
'whichever is wrong.',
|
|
84
|
+
details: { verb: verb.name, stage: :output, errors: errors, answer: answer }
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
module Conformance
|
|
6
|
+
module Checks
|
|
7
|
+
# Called with these arguments as this principal, the verb answers rather than refusing.
|
|
8
|
+
module Executes
|
|
9
|
+
extend Helpers
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(origin, name, params:, as:)
|
|
14
|
+
verb = find_verb(origin, name)
|
|
15
|
+
return verb if verb.is_a?(Outcome)
|
|
16
|
+
|
|
17
|
+
run(origin, verb, params, arguments_for(verb, params), as)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run(origin, verb, params, arguments, as)
|
|
21
|
+
begin
|
|
22
|
+
answer = origin.call(verb.name, kind: verb.kind, params: arguments, as: as)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
return refused(verb, params, arguments, e)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
answered(verb, params, arguments, answer)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def refused(verb, params, arguments, error)
|
|
31
|
+
Conformance.fail(
|
|
32
|
+
:executes, subject: verb.name,
|
|
33
|
+
message: "#{verb} did not execute: #{describe_error(error)}. " \
|
|
34
|
+
"Arguments were #{arguments.inspect}#{params_provenance(verb, params)}.",
|
|
35
|
+
details: error_details(verb, arguments, error)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def error_details(verb, arguments, error)
|
|
40
|
+
{ verb: verb.name, kind: verb.kind, error: error.class.name, params: arguments,
|
|
41
|
+
error_code: (error.code if error.respond_to?(:code)),
|
|
42
|
+
error_status: (error.http_status if error.respond_to?(:http_status)) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def answered(verb, params, arguments, answer)
|
|
46
|
+
Conformance.pass(
|
|
47
|
+
:executes, subject: verb.name,
|
|
48
|
+
message: "#{verb} executed#{params_provenance(verb, params)} and answered " \
|
|
49
|
+
"#{summarise_answer(answer)}",
|
|
50
|
+
details: { verb: verb.name, kind: verb.kind, params: arguments, answer: answer }
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'json_schemer'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module TestHelpers
|
|
8
|
+
module Conformance
|
|
9
|
+
module Checks
|
|
10
|
+
# `params:` default: the verb's own `example_params`, else `{}`.
|
|
11
|
+
EXAMPLE = :__kiosk_example_params__
|
|
12
|
+
|
|
13
|
+
# Refusing the second principal outright scopes as well as narrowing does;
|
|
14
|
+
# a 400 or 500 stays a failure.
|
|
15
|
+
SCOPING_REFUSALS = [403, 404].freeze
|
|
16
|
+
|
|
17
|
+
# Functions the four checks share.
|
|
18
|
+
module Helpers
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The verb by name, or a failing Outcome listing the declared ones.
|
|
22
|
+
def find_verb(origin, name)
|
|
23
|
+
wanted = name.to_s
|
|
24
|
+
found = origin.verbs.find { |verb| verb.name == wanted }
|
|
25
|
+
return found if found
|
|
26
|
+
|
|
27
|
+
known = origin.verbs.map(&:name).sort
|
|
28
|
+
Conformance.fail(
|
|
29
|
+
:unknown_verb, subject: wanted,
|
|
30
|
+
message: "no verb named #{wanted.inspect} is declared by this origin. " \
|
|
31
|
+
"Declared: #{known.empty? ? '(none at all)' : known.join(', ')}.",
|
|
32
|
+
details: { asked: wanted, known: known }
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# «403 forbidden», or nil when the outcome is not a scoping refusal.
|
|
37
|
+
def refusal_status(outcome)
|
|
38
|
+
return nil unless outcome.failed?
|
|
39
|
+
|
|
40
|
+
status = outcome.details[:error_status]
|
|
41
|
+
return nil unless SCOPING_REFUSALS.include?(status)
|
|
42
|
+
|
|
43
|
+
[status, outcome.details[:error_code]].compact.join(' ')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def principal_label(subject)
|
|
47
|
+
subject.respond_to?(:id) ? "#{subject.class}(#{subject.id})" : subject.inspect
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def arguments_for(verb, params)
|
|
51
|
+
return normalize(params) unless params.equal?(EXAMPLE)
|
|
52
|
+
|
|
53
|
+
normalize(verb.example_params || {})
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def params_provenance(verb, params)
|
|
57
|
+
return '' unless params.equal?(EXAMPLE)
|
|
58
|
+
|
|
59
|
+
verb.example_params.nil? ? ' (no arguments)' : ' with its own example_params'
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Some routers raise for "no route", others return nil.
|
|
63
|
+
def recognize(origin, path, http_method)
|
|
64
|
+
origin.recognize(path, method: http_method)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Schema failures as strings; an origin that validates for itself answers them.
|
|
70
|
+
def schema_errors(origin, payload, schema, verb, slot)
|
|
71
|
+
if origin.respond_to?(:schema_errors)
|
|
72
|
+
return Array(origin.schema_errors(payload, schema: schema, verb: verb.name,
|
|
73
|
+
kind: verb.kind, slot: slot))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
JSONSchemer.schema(normalize(schema)).validate(normalize(payload)).map do |error|
|
|
77
|
+
pointer = error['data_pointer'].to_s
|
|
78
|
+
"#{pointer.empty? ? '(root)' : pointer}: #{error['error']}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The value as a caller receives it, after the JSON round trip.
|
|
83
|
+
def normalize(value)
|
|
84
|
+
JSON.parse(JSON.generate([value])).first
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# An action's single object counts as one row.
|
|
88
|
+
def rows_of(answer)
|
|
89
|
+
normalized = normalize(answer)
|
|
90
|
+
normalized.is_a?(Array) ? normalized : [normalized].compact
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def summarise_answer(answer)
|
|
94
|
+
case answer
|
|
95
|
+
when Array then "#{answer.length} row(s)"
|
|
96
|
+
when Hash then "an object with keys #{answer.keys.map(&:to_s).sort.inspect}"
|
|
97
|
+
when nil then 'nothing'
|
|
98
|
+
else answer.class.name
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def describe_error(error)
|
|
103
|
+
parts = ["#{error.class.name}: #{error.message}"]
|
|
104
|
+
parts << "code=#{error.code}" if error.respond_to?(:code) && error.code
|
|
105
|
+
parts << "hint: #{error.hint}" if error.respond_to?(:hint) && error.hint
|
|
106
|
+
parts.join(', ')
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
module Conformance
|
|
6
|
+
module Checks
|
|
7
|
+
# No row `as:` sees reaches `and_not:`, compared whole-row. An empty first
|
|
8
|
+
# answer fails (no positive control), and so does a `published` verb.
|
|
9
|
+
module PrincipalScope
|
|
10
|
+
extend Helpers
|
|
11
|
+
|
|
12
|
+
# What each principal saw. `refused` is the scoping refusal, or nil.
|
|
13
|
+
Sighting = Data.define(:verb, :as, :and_not, :own, :other, :refused)
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def call(origin, name, as:, and_not:, params:)
|
|
18
|
+
verb = find_verb(origin, name)
|
|
19
|
+
return verb if verb.is_a?(Outcome)
|
|
20
|
+
return published(verb) if verb.reach == 'published'
|
|
21
|
+
|
|
22
|
+
compare(origin, verb, arguments_for(verb, params), as, and_not)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def compare(origin, verb, arguments, as, and_not)
|
|
26
|
+
mine = Checks.executes(origin, verb.name, params: arguments, as: as)
|
|
27
|
+
return mine if mine.failed?
|
|
28
|
+
|
|
29
|
+
theirs = Checks.executes(origin, verb.name, params: arguments, as: and_not)
|
|
30
|
+
refused = refusal_status(theirs)
|
|
31
|
+
return theirs if theirs.failed? && refused.nil?
|
|
32
|
+
|
|
33
|
+
other = refused ? [] : rows_of(theirs.details[:answer])
|
|
34
|
+
verdict(Sighting.new(verb: verb, as: as, and_not: and_not,
|
|
35
|
+
own: rows_of(mine.details[:answer]), other: other, refused: refused))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def verdict(sighting)
|
|
39
|
+
return nothing_own(sighting) if sighting.own.empty?
|
|
40
|
+
|
|
41
|
+
leaked = sighting.own & sighting.other
|
|
42
|
+
leaked.empty? ? scoped(sighting) : leak(sighting, leaked)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def published(verb)
|
|
46
|
+
Conformance.fail(
|
|
47
|
+
:principal_scope, subject: verb.name,
|
|
48
|
+
message: "#{verb} declares `reach: :published`, which says every authenticated " \
|
|
49
|
+
'caller sees the same rows. Asserting that two principals see different ' \
|
|
50
|
+
'rows asserts the opposite of the descriptor — assert the descriptor, or ' \
|
|
51
|
+
'narrow the reach.',
|
|
52
|
+
details: { verb: verb.name, reach: verb.reach }
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def nothing_own(sighting)
|
|
57
|
+
verb = sighting.verb
|
|
58
|
+
Conformance.fail(
|
|
59
|
+
:principal_scope, subject: verb.name,
|
|
60
|
+
message: "#{verb} answered #{principal_label(sighting.as)} with nothing, so there is nothing for " \
|
|
61
|
+
"#{principal_label(sighting.and_not)} to be scoped out of. Seed a row that belongs to the " \
|
|
62
|
+
'first principal — a scoping assertion with no positive control passes ' \
|
|
63
|
+
'on a verb that answers everybody with nothing.',
|
|
64
|
+
details: { verb: verb.name, reach: verb.reach, own: sighting.own, other: sighting.other }
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def scoped(sighting)
|
|
69
|
+
verb = sighting.verb
|
|
70
|
+
Conformance.pass(
|
|
71
|
+
:principal_scope, subject: verb.name,
|
|
72
|
+
message: sighting.refused ? refused_message(sighting) : narrowed_message(sighting),
|
|
73
|
+
details: { verb: verb.name, reach: verb.reach, own: sighting.own,
|
|
74
|
+
other: sighting.other, refused: sighting.refused }
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def refused_message(sighting)
|
|
79
|
+
"#{sighting.verb} answered #{principal_label(sighting.as)} with #{sighting.own.length} row(s) and REFUSED " \
|
|
80
|
+
"#{principal_label(sighting.and_not)} outright (#{sighting.refused}), which is the strongest " \
|
|
81
|
+
"spelling of reach: :#{sighting.verb.reach}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def narrowed_message(sighting)
|
|
85
|
+
"#{sighting.verb} answered #{principal_label(sighting.as)} with #{sighting.own.length} row(s), none of " \
|
|
86
|
+
"which reached #{principal_label(sighting.and_not)} (declared reach: #{sighting.verb.reach})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def leak(sighting, rows)
|
|
90
|
+
verb = sighting.verb
|
|
91
|
+
Conformance.fail(
|
|
92
|
+
:principal_scope, subject: verb.name,
|
|
93
|
+
message: "#{verb} is declared `reach: :#{verb.reach}` but leaked #{rows.length} " \
|
|
94
|
+
"row(s) belonging to #{principal_label(sighting.as)} into " \
|
|
95
|
+
"#{principal_label(sighting.and_not)}'s answer: " \
|
|
96
|
+
"#{rows.first(3).inspect}. Scope the handler to the calling principal.",
|
|
97
|
+
details: { verb: verb.name, reach: verb.reach, leaked: rows }
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
module Conformance
|
|
6
|
+
module Checks
|
|
7
|
+
# Every declared verb is routed under its own name to the action that answers it.
|
|
8
|
+
module Routes
|
|
9
|
+
extend Helpers
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(origin)
|
|
14
|
+
verbs = origin.verbs
|
|
15
|
+
return no_verbs if verbs.empty?
|
|
16
|
+
|
|
17
|
+
mount = origin.mount_path.to_s.chomp('/')
|
|
18
|
+
problems = verbs.flat_map { |verb| problems_for(origin, verb, mount) }
|
|
19
|
+
problems.empty? ? routed(verbs, mount) : unrouted(verbs, mount, problems)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def no_verbs
|
|
23
|
+
Conformance.fail(
|
|
24
|
+
:routes,
|
|
25
|
+
message: 'this origin declares no verbs at all, so there is nothing to route. ' \
|
|
26
|
+
'Either app/controllers/kiosk holds no handler controller, ' \
|
|
27
|
+
'or the registry was read before the engine populated it — build the ' \
|
|
28
|
+
'origin inside the example, not at file load.'
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def routed(verbs, mount)
|
|
33
|
+
Conformance.pass(
|
|
34
|
+
:routes,
|
|
35
|
+
message: "all #{verbs.length} declared verbs are routed under #{mount}/ " \
|
|
36
|
+
'with the method their kind requires',
|
|
37
|
+
details: { verbs: verbs.map(&:name), mount_path: mount }
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unrouted(verbs, mount, problems)
|
|
42
|
+
Conformance.fail(
|
|
43
|
+
:routes,
|
|
44
|
+
message: "#{problems.length} of this origin's #{verbs.length} declared verbs are " \
|
|
45
|
+
"not routed as their kind requires:\n - #{problems.join("\n - ")}",
|
|
46
|
+
details: { problems: problems, verbs: verbs.map(&:name), mount_path: mount }
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def problems_for(origin, verb, mount)
|
|
51
|
+
path = "#{mount}/#{verb.name}"
|
|
52
|
+
found = recognize(origin, path, verb.http_method)
|
|
53
|
+
return [not_routed(verb, path)] if found.nil?
|
|
54
|
+
|
|
55
|
+
[wrong_endpoint(verb, path, found), wrong_name(verb, path, found),
|
|
56
|
+
other_method(origin, verb, path)].compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def not_routed(verb, path)
|
|
60
|
+
"#{verb}: nothing answers #{verb.http_method} #{path} — declared and " \
|
|
61
|
+
'never routed, which is a 404 to every caller'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def wrong_endpoint(verb, path, found)
|
|
65
|
+
endpoint = "#{found[:controller]}##{found[:action]}"
|
|
66
|
+
return if endpoint == verb.endpoint
|
|
67
|
+
|
|
68
|
+
"#{verb}: #{verb.http_method} #{path} reaches #{endpoint}, not " \
|
|
69
|
+
"#{verb.endpoint} — a route drawn straight at a handler answers " \
|
|
70
|
+
'404 to every caller; name the verb in the `kiosk` block'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def wrong_name(verb, path, found)
|
|
74
|
+
routed_name = found[:kiosk_verb].to_s
|
|
75
|
+
return if routed_name == verb.name
|
|
76
|
+
|
|
77
|
+
"#{verb}: #{verb.http_method} #{path} hands the wire " \
|
|
78
|
+
"kiosk_verb: #{routed_name.inspect} — the path segment and the " \
|
|
79
|
+
'`defaults:` are two spellings of one name and must match'
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def other_method(origin, verb, path)
|
|
83
|
+
other = recognize(origin, path, verb.other_http_method)
|
|
84
|
+
return unless other && other[:kiosk_verb].to_s == verb.name
|
|
85
|
+
|
|
86
|
+
"#{verb}: #{path} also reaches the verb on " \
|
|
87
|
+
"#{verb.other_http_method} — the method follows the kind, so a " \
|
|
88
|
+
"#{verb.kind} answers #{verb.http_method} and nothing else"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kiosk/test_helpers/conformance/outcome'
|
|
4
|
+
require 'kiosk/test_helpers/conformance/verb'
|
|
5
|
+
require 'kiosk/test_helpers/conformance/checks/helpers'
|
|
6
|
+
require 'kiosk/test_helpers/conformance/checks/routes'
|
|
7
|
+
require 'kiosk/test_helpers/conformance/checks/executes'
|
|
8
|
+
require 'kiosk/test_helpers/conformance/checks/declared_shape'
|
|
9
|
+
require 'kiosk/test_helpers/conformance/checks/principal_scope'
|
|
10
|
+
|
|
11
|
+
module Kiosk
|
|
12
|
+
module TestHelpers
|
|
13
|
+
module Conformance
|
|
14
|
+
# The four conformance checks, as pure functions of an origin returning an
|
|
15
|
+
# {Outcome}; the Minitest and RSpec adapters render its message verbatim.
|
|
16
|
+
module Checks
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Every declared verb is routed under its own name to the action that
|
|
20
|
+
# answers it, GET for a query and POST for an action. No verbs at all fails.
|
|
21
|
+
def routes(origin)
|
|
22
|
+
Routes.call(origin)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Called with these arguments as this principal, the verb answers rather than refusing.
|
|
26
|
+
def executes(origin, name, params: EXAMPLE, as: nil)
|
|
27
|
+
Executes.call(origin, name, params: params, as: as)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The arguments satisfy the verb's input_schema and its answer satisfies
|
|
31
|
+
# its output_schema; the two failures are reported separately.
|
|
32
|
+
def declared_shape(origin, name, params: EXAMPLE, as: nil)
|
|
33
|
+
DeclaredShape.call(origin, name, params: params, as: as)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# No row `as:` sees reaches `and_not:`, compared whole-row. An empty first
|
|
37
|
+
# answer fails, because a scoping assertion with no positive control passes
|
|
38
|
+
# on a verb that answers everybody with nothing. A `published` verb fails too.
|
|
39
|
+
def principal_scope(origin, name, as:, and_not:, params: EXAMPLE)
|
|
40
|
+
PrincipalScope.call(origin, name, as: as, and_not: and_not, params: params)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|