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,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