kiosk-test-support 0.5.20 → 0.6.1
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 +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +7 -5
- data/lib/kiosk/test_helpers/assistant/events.rb +10 -8
- data/lib/kiosk/test_helpers/assistant.rb +91 -71
- data/lib/kiosk/test_helpers/customer.rb +15 -20
- data/lib/kiosk/test_helpers/dpop.rb +59 -0
- data/lib/kiosk/test_helpers/in_process_wire.rb +29 -15
- data/lib/kiosk/test_helpers/kyc.rb +3 -3
- data/lib/kiosk/test_helpers/live_server.rb +5 -1
- data/lib/kiosk/test_helpers/mandate.rb +46 -0
- data/lib/kiosk/test_helpers/pow_solver.rb +19 -0
- data/lib/kiosk/test_helpers/story.rb +1 -1
- data/lib/kiosk/test_helpers/version.rb +1 -1
- data/lib/kiosk/test_helpers/wire.rb +32 -9
- metadata +22 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c7eb9fc194090087a2bd0a1b771861d165f3fea55a0fbfd832ba2d1d0037a08
|
|
4
|
+
data.tar.gz: a862919840551f535559671b236ac98a84dc420e07dba3c706c30d105032b4a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 49f01347f6643ef9032955d55064c8555000336efa5a507073d29e16cd41e2c12cd851407b59ac4bc74e1c4c9dd6272e0c9cbe75aada517746c4484f8d0783ad
|
|
7
|
+
data.tar.gz: dbe42526621ccd03a3153db7d2ed4338892915d8b6cd2975478eb4079e8034c7edcbf7a083298f5cab0f3b13e8337bf8cbe3d9a9e935b13ea8cfc53dfa38a622
|
data/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,48 @@ already written is edited.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.6.1] — 2026-10-22
|
|
16
|
+
|
|
17
|
+
- 2026-10-22: Version 0.6.1, the tree cut that matches skill 0.6.1.
|
|
18
|
+
- 2026-10-21: Drops the `kiosk-pow-equihash` and `puma` dependencies; the host's bundle brings them. `Wire` names the gem to add, and refuses a toll in another proof of work.
|
|
19
|
+
|
|
20
|
+
## [0.6.0] — 2026-10-21
|
|
21
|
+
|
|
22
|
+
- 2026-10-21: The helpers make Ed25519 assistant keys and sign EdDSA; `Principal#key` replaces `#rsa_key`, and `Dpop.generate_key('ES256')` makes a P-256 key.
|
|
23
|
+
- 2026-10-21: Version 0.6.0, the tree cut that matches skill 0.6.0.
|
|
24
|
+
|
|
25
|
+
## [0.5.27] — 2026-10-21
|
|
26
|
+
|
|
27
|
+
- 2026-10-21: `Assistant` registers at `/kiosk/oauth/register` and gets tokens by client assertion and DPoP; `login`, `proof_of_possession` and the challenge are gone.
|
|
28
|
+
- 2026-10-21: Version 0.5.27, the tree cut that matches skill 0.5.27.
|
|
29
|
+
|
|
30
|
+
## [0.5.26] — 2026-10-21
|
|
31
|
+
|
|
32
|
+
- 2026-10-21: Version 0.5.26, the tree cut that matches skill 0.5.26.
|
|
33
|
+
|
|
34
|
+
## [0.5.25] — 2026-10-21
|
|
35
|
+
|
|
36
|
+
- 2026-10-21: Version 0.5.25, the tree cut that matches skill 0.5.25.
|
|
37
|
+
|
|
38
|
+
## [0.5.24] — 2026-10-21
|
|
39
|
+
|
|
40
|
+
- 2026-10-21: The gemspec depends on kiosk-server and kiosk-user-idp-devise, which the in-process helpers and `Person` require, so a bundle with this gem alone loads them.
|
|
41
|
+
- 2026-10-21: Version 0.5.24, the tree cut that matches skill 0.5.24.
|
|
42
|
+
|
|
43
|
+
## [0.5.23] — 2026-10-21
|
|
44
|
+
|
|
45
|
+
- 2026-10-21: `Wire#dpop` replaces `Wire#bearer`: each call sends the token as `Authorization: DPoP` with a new proof. `Assistant#rotate` puts a new key on a principal.
|
|
46
|
+
- 2026-10-21: Version 0.5.23, the tree cut that matches skill 0.5.23.
|
|
47
|
+
|
|
48
|
+
## [0.5.22] — 2026-10-21
|
|
49
|
+
|
|
50
|
+
- 2026-10-21: `Kiosk::TestHelpers::Mandate` signs the two AP2 v0.2 mandates; `Assistant#pay` and `Customer#pays` take a `checkout_jwt`.
|
|
51
|
+
- 2026-10-21: Version 0.5.22, the tree cut that matches skill 0.5.22.
|
|
52
|
+
|
|
53
|
+
## [0.5.21] — 2026-10-21
|
|
54
|
+
|
|
55
|
+
- 2026-10-21: Version 0.5.21, the tree cut that matches skill 0.5.21.
|
|
56
|
+
|
|
15
57
|
## [0.5.20] — 2026-10-20
|
|
16
58
|
|
|
17
59
|
- 2026-10-20: `InProcessOrigin` and `InProcessExecutor` call a verb through its route with a linked assistant's token; `InProcessWire` does the call.
|
data/README.md
CHANGED
|
@@ -29,6 +29,8 @@ end
|
|
|
29
29
|
|
|
30
30
|
Both harnesses pull `kiosk-test-support` as a transitive dependency.
|
|
31
31
|
|
|
32
|
+
Your bundle brings what the helpers drive: `puma` for `LiveServer` (`rails new` writes it), and `kiosk-pow-equihash` for the solver that pays Equihash tolls. `Wire` solves no other proof of work.
|
|
33
|
+
|
|
32
34
|
## DSL
|
|
33
35
|
|
|
34
36
|
```ruby
|
|
@@ -69,7 +71,7 @@ The `NullExecutor` is the zero-dependency fallback for unit-shaped tests; produc
|
|
|
69
71
|
|
|
70
72
|
## Driving the wire as an assistant
|
|
71
73
|
|
|
72
|
-
`Kiosk::TestHelpers::Assistant` registers, pays proof-of-work tolls, queries, runs, pays with
|
|
74
|
+
`Kiosk::TestHelpers::Assistant` registers its key as an OAuth client and gets DPoP-bound tokens, pays proof-of-work tolls, queries, runs, pays with closed AP2 mandates (SD-JWT) and listens on `<endpoint>/events`, against any origin — a test's own live server or a deployment:
|
|
73
75
|
|
|
74
76
|
```ruby
|
|
75
77
|
require "kiosk/test_helpers/assistant"
|
|
@@ -90,7 +92,7 @@ Every answer carries its status, parsed body and headers. `Kiosk::TestHelpers::W
|
|
|
90
92
|
```ruby
|
|
91
93
|
class Shopper < Kiosk::TestHelpers::Customer
|
|
92
94
|
def orders(*skus) = does(:create_order, items: skus.map { { sku: _1, qty: 1 } })
|
|
93
|
-
def pays_for(order) = pays(
|
|
95
|
+
def pays_for(order) = pays(order["checkout_jwt"])
|
|
94
96
|
end
|
|
95
97
|
|
|
96
98
|
class ShopStory < Kiosk::StoryTest
|
|
@@ -101,11 +103,11 @@ class ShopStory < Kiosk::StoryTest
|
|
|
101
103
|
end
|
|
102
104
|
```
|
|
103
105
|
|
|
104
|
-
- `a_customer(as:)` registers an assistant; `a_newcomer(as:)`
|
|
106
|
+
- `a_customer(as:)` registers an assistant; `a_newcomer(as:)` registers a key and acts for no person yet; `published(path)` reads what the origin shows anyone.
|
|
105
107
|
- `Customer#asks` and `#does` answer an `Answer` (`ok?`, `refused?(code)`, `detail`, `hint`, `rows`, `header`, `tolls_paid`, `solved_toll`, `next_page`); `asks(…, unpaid: true)` or `asks(…, proofs: […])` leaves the toll to the story.
|
|
106
108
|
- `Kiosk::StoryTest#assert_refused(answer, code)` asserts the wire refused the `Answer` with `code`.
|
|
107
|
-
- `Customer#sets_up_payment` and `#requests_verification` run the engine's `payment_setup` and `request_kyc`, and `#hears_verification_passed` waits for the outcome of the check it asked for last; `#pays(
|
|
108
|
-
- `Customer#account`, `#role` and `#claims` read its credential; `#signs_back_in
|
|
109
|
+
- `Customer#sets_up_payment` and `#requests_verification` run the engine's `payment_setup` and `request_kyc`, and `#hears_verification_passed` waits for the outcome of the check it asked for last; `#pays(checkout_jwt)` signs the two closed AP2 mandates for the checkout an action answered, unchanged, and pays; on `payment_setup_required` it calls `payment_setup` and pays once more.
|
|
110
|
+
- `Customer#account`, `#role` and `#claims` read its credential; `#signs_back_in` and `#with_a_fresh_credential` ask the token endpoint for a new token; `#redeems(link_code)`, `#asks_to_be_linked`, `#polls` and `#collects` are the linking ceremonies.
|
|
109
111
|
- `Customer#listens_for`, `#follows(*topics, subject:, since:)` and `#hears(topic, about:, on:, **data)` read the event stream, holding each heard payload to the schema the origin publishes for its topic.
|
|
110
112
|
- `a_person(email:, password:)` signs a person in on the operator's site through `kiosk-user-idp-devise`'s `DeviseSession`: `links(customer)`, `link_code`, `approves(user_code)`, `unlinks(customer)`, `visits(path)`.
|
|
111
113
|
|
|
@@ -6,6 +6,7 @@ require 'openssl'
|
|
|
6
6
|
require 'socket'
|
|
7
7
|
require 'uri'
|
|
8
8
|
require 'websocket/driver'
|
|
9
|
+
require 'kiosk/test_helpers/dpop'
|
|
9
10
|
|
|
10
11
|
module Kiosk
|
|
11
12
|
module TestHelpers
|
|
@@ -35,14 +36,15 @@ module Kiosk
|
|
|
35
36
|
|
|
36
37
|
attr_reader :url
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@
|
|
39
|
+
# `key` signs the DPoP proof of the upgrade; its `htu` is the events URL
|
|
40
|
+
# with the HTTP scheme, because the upgrade is an HTTP request there.
|
|
41
|
+
def initialize(base_url:, token:, key:, path: '/kiosk/events', timeout: 10)
|
|
42
|
+
http = URI.join(base_url, path)
|
|
43
|
+
@url = http.to_s.sub(/\Ahttp/, 'ws')
|
|
44
|
+
@io = open_socket(http.hostname, http.port, http.scheme == 'https')
|
|
43
45
|
@frames = []
|
|
44
46
|
@closed = false
|
|
45
|
-
@driver = start_driver(token)
|
|
47
|
+
@driver = start_driver(Dpop.headers(token, key, method: 'GET', url: http.to_s))
|
|
46
48
|
pump(timeout) { @frames.any? { it['type'] == 'welcome' } } || raise(Error, "#{@url} did not welcome the connection")
|
|
47
49
|
end
|
|
48
50
|
|
|
@@ -98,9 +100,9 @@ module Kiosk
|
|
|
98
100
|
ssl
|
|
99
101
|
end
|
|
100
102
|
|
|
101
|
-
def start_driver(
|
|
103
|
+
def start_driver(headers)
|
|
102
104
|
driver = WebSocket::Driver.client(self)
|
|
103
|
-
driver.set_header(
|
|
105
|
+
headers.each { |name, value| driver.set_header(name, value) }
|
|
104
106
|
driver.on(:message) { @frames << JSON.parse(it.data) }
|
|
105
107
|
driver.on(:close) { @closed = true }
|
|
106
108
|
driver.on(:error) { @closed = true }
|
|
@@ -6,12 +6,14 @@ require 'securerandom'
|
|
|
6
6
|
require 'uri'
|
|
7
7
|
require 'jwt'
|
|
8
8
|
require 'kiosk/test_helpers/wire'
|
|
9
|
+
require 'kiosk/test_helpers/mandate'
|
|
9
10
|
require 'kiosk/test_helpers/assistant/events'
|
|
10
11
|
|
|
11
12
|
module Kiosk
|
|
12
13
|
module TestHelpers
|
|
13
|
-
# An assistant on the wire: registers
|
|
14
|
-
#
|
|
14
|
+
# An assistant on the wire: registers its key as an OAuth client, then
|
|
15
|
+
# queries, runs and pays as the principal its token names, paying every
|
|
16
|
+
# proof-of-work toll it is asked for.
|
|
15
17
|
#
|
|
16
18
|
# assistant = Kiosk::TestHelpers::Assistant.new(base_url: live_url)
|
|
17
19
|
# rider = assistant.register!
|
|
@@ -19,8 +21,12 @@ module Kiosk
|
|
|
19
21
|
class Assistant
|
|
20
22
|
class RegistrationError < StandardError; end
|
|
21
23
|
|
|
22
|
-
# The account an assistant
|
|
23
|
-
|
|
24
|
+
# The account an assistant acts for, its client_id (the agent_id), and
|
|
25
|
+
# the key that signs its client assertions, DPoP proofs and mandates.
|
|
26
|
+
Principal = Data.define(:agent_id, :user_id, :token, :key)
|
|
27
|
+
|
|
28
|
+
ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
29
|
+
DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code'
|
|
24
30
|
|
|
25
31
|
attr_reader :wire
|
|
26
32
|
|
|
@@ -30,119 +36,133 @@ module Kiosk
|
|
|
30
36
|
|
|
31
37
|
def base_url = wire.base_url
|
|
32
38
|
|
|
33
|
-
# `pow:` is `:solve`, `:skip` (send no proof)
|
|
34
|
-
# the proof; `wire_role:` puts a `role` the
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
# Registers `key` (RFC 7591). `pow:` is `:solve`, `:skip` (send no proof)
|
|
40
|
+
# or a String sent verbatim as the proof; `wire_role:` puts a `role` the
|
|
41
|
+
# engine must ignore into the body.
|
|
42
|
+
def register_raw(pow: :solve, wire_role: nil, key: Dpop.generate_key)
|
|
43
|
+
body = { jwks: jwks(key), token_endpoint_auth_method: 'private_key_jwt' }
|
|
44
|
+
body[:role] = wire_role if wire_role
|
|
45
|
+
case pow
|
|
46
|
+
when :solve then wire.post('/kiosk/oauth/register', body)
|
|
47
|
+
when :skip then unpaid.post('/kiosk/oauth/register', body)
|
|
48
|
+
else unpaid.post('/kiosk/oauth/register', body, 'Kiosk-PoW' => pow)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A new key, registered: the principal has a client_id and no token yet.
|
|
53
|
+
def register_client!(pow: :solve)
|
|
54
|
+
key = Dpop.generate_key
|
|
55
|
+
resp = register_raw(pow:, key:)
|
|
56
|
+
raise RegistrationError, "registration expected 201, got #{resp.status}: #{resp.body.inspect}" unless resp.status == 201
|
|
57
|
+
|
|
58
|
+
Principal.new(agent_id: resp.body.fetch('client_id'), user_id: nil, token: nil, key: key)
|
|
37
59
|
end
|
|
38
60
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
raise RegistrationError, "register! expected 201, got #{response.status}: #{response.body.inspect}" unless response.status == 201
|
|
61
|
+
# A new key, registered, holding its first token.
|
|
62
|
+
def register!(pow: :solve) = signed_in(register_client!(pow:))
|
|
42
63
|
|
|
43
|
-
|
|
44
|
-
|
|
64
|
+
# The principal holding a new token from the client_credentials grant.
|
|
65
|
+
def signed_in(principal)
|
|
66
|
+
response = token(principal)
|
|
67
|
+
raise RegistrationError, "token request expected 200, got #{response.status}: #{response.body.inspect}" unless response.status == 200
|
|
68
|
+
|
|
69
|
+
access_token = response.body.fetch('access_token')
|
|
70
|
+
principal.with(token: access_token, user_id: JWT.decode(access_token, nil, false).first['sub'])
|
|
45
71
|
end
|
|
46
72
|
|
|
47
|
-
#
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
# Asks the token endpoint for a token of the principal's client (RFC 6749 §4.4).
|
|
74
|
+
def token(principal) = token_request(principal, 'grant_type' => 'client_credentials')
|
|
75
|
+
|
|
76
|
+
# Polls for the token a person grants on the operator's site (RFC 8628 §3.4).
|
|
77
|
+
def device_token(device_code, principal)
|
|
78
|
+
token_request(principal, 'grant_type' => DEVICE_CODE, 'device_code' => device_code)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Opens the account-binding ceremony (RFC 8628 §3.1), with whatever else the caller sends.
|
|
82
|
+
def device_authorization(principal, **extra)
|
|
83
|
+
wire.post_form('/kiosk/oauth/device_authorization', assertion_params(principal).merge(extra.transform_keys(&:to_s)))
|
|
51
84
|
end
|
|
52
85
|
|
|
53
86
|
def kyc(principal, attestation_jws:)
|
|
54
|
-
wire.post('/kiosk/agents/kyc', { kyc_jws: attestation_jws },
|
|
87
|
+
wire.post('/kiosk/agents/kyc', { kyc_jws: attestation_jws }, credential(principal))
|
|
55
88
|
end
|
|
56
89
|
|
|
57
90
|
# `pay_tolls: false` answers a proof-of-work toll instead of paying it.
|
|
58
91
|
def query(principal, name:, headers: {}, pay_tolls: true, **params)
|
|
59
|
-
(pay_tolls ? wire : unpaid).get("/kiosk/#{name}", params,
|
|
92
|
+
(pay_tolls ? wire : unpaid).get("/kiosk/#{name}", params, credential(principal).merge(headers))
|
|
60
93
|
end
|
|
61
94
|
|
|
62
95
|
def run(principal, name:, headers: {}, **args)
|
|
63
|
-
wire.post("/kiosk/#{name}", args,
|
|
96
|
+
wire.post("/kiosk/#{name}", args, credential(principal).merge(headers))
|
|
64
97
|
end
|
|
65
98
|
|
|
66
|
-
#
|
|
67
|
-
def
|
|
68
|
-
|
|
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))
|
|
99
|
+
# Signs the two closed AP2 mandates for `checkout_jwt`, unchanged, and pays.
|
|
100
|
+
def pay(principal, checkout_jwt, instrument: 'on_file')
|
|
101
|
+
pay_raw(principal, **Mandate.pay(principal.key, checkout_jwt, instrument:))
|
|
78
102
|
end
|
|
79
103
|
|
|
80
104
|
# On a 402 `payment_setup_required` it calls `payment_setup`, as the hint
|
|
81
105
|
# asks, and sends the same mandates once more. Against stripe-mock the
|
|
82
106
|
# Customer that `payment_setup` maps already lists a card, so that answer settles.
|
|
83
|
-
def pay_raw(principal,
|
|
84
|
-
body = {
|
|
85
|
-
answer = wire.post('/kiosk/pay', body,
|
|
107
|
+
def pay_raw(principal, checkout:, payment:)
|
|
108
|
+
body = { checkout_mandate: checkout, payment_mandate: payment }
|
|
109
|
+
answer = wire.post('/kiosk/pay', body, credential(principal))
|
|
86
110
|
return answer unless setup_required?(answer)
|
|
87
111
|
|
|
88
112
|
run(principal, name: 'payment_setup')
|
|
89
|
-
wire.post('/kiosk/pay', body,
|
|
113
|
+
wire.post('/kiosk/pay', body, credential(principal))
|
|
90
114
|
end
|
|
91
115
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
116
|
+
# Puts a new key on the principal's client (spec Section 5.7): the old
|
|
117
|
+
# key's assertion names the new key, and the new key signs its own. The
|
|
118
|
+
# answer's Principal holds the new key and a token bound to it.
|
|
119
|
+
def rotate(principal, new_key: Dpop.generate_key)
|
|
120
|
+
body = { client_assertion_type: ASSERTION_TYPE,
|
|
121
|
+
client_assertion: client_assertion(principal, new_key: Dpop.thumbprint(new_key)),
|
|
122
|
+
new_client_assertion: client_assertion(principal.with(key: new_key)), jwks: jwks(new_key) }
|
|
123
|
+
response = wire.post('/kiosk/auth/rotate', body)
|
|
124
|
+
return response unless response.status == 200
|
|
125
|
+
|
|
126
|
+
signed_in(principal.with(key: new_key))
|
|
98
127
|
end
|
|
99
128
|
|
|
100
|
-
|
|
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
|
|
129
|
+
# Redeems a link code a person minted on the operator's site; answers 204.
|
|
130
|
+
def claim(code, principal) = wire.post('/kiosk/auth/claim', { code:, **assertion_params(principal) })
|
|
113
131
|
|
|
114
|
-
# A
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
# A client assertion (RFC 7523 §3) that the principal's key signs for this
|
|
133
|
+
# origin. `claims` adds or replaces members, such as the `new_key` of a rotation.
|
|
134
|
+
def client_assertion(principal, **claims)
|
|
135
|
+
payload = { iss: principal.agent_id, sub: principal.agent_id, aud: base_url, exp: Time.now.to_i + 60,
|
|
136
|
+
jti: SecureRandom.uuid, **claims }
|
|
137
|
+
JWT.encode(payload, principal.key, Dpop.algorithm(principal.key))
|
|
118
138
|
end
|
|
119
139
|
|
|
120
140
|
# What the origin publishes about itself at `/kiosk/schema`.
|
|
121
141
|
def schema = @schema ||= wire.get_json('/kiosk/schema').last
|
|
122
142
|
|
|
123
|
-
def events(principal) = Events.new(base_url:, token: principal.token).tap { (@connections ||= []) << it }
|
|
143
|
+
def events(principal) = Events.new(base_url:, token: principal.token, key: principal.key).tap { (@connections ||= []) << it }
|
|
124
144
|
|
|
125
145
|
# Closes every event connection it opened.
|
|
126
146
|
def disconnect = @connections&.each(&:close)
|
|
127
147
|
|
|
128
148
|
private
|
|
129
149
|
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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]
|
|
150
|
+
def jwks(key) = { keys: [Dpop.jwk(key)] }
|
|
151
|
+
|
|
152
|
+
def assertion_params(principal)
|
|
153
|
+
{ 'client_assertion_type' => ASSERTION_TYPE, 'client_assertion' => client_assertion(principal) }
|
|
140
154
|
end
|
|
141
155
|
|
|
142
|
-
|
|
156
|
+
# A token request carries a DPoP proof of the registered key, with no token yet.
|
|
157
|
+
def token_request(principal, form)
|
|
158
|
+
wire.post_form('/kiosk/oauth/token', form.merge(assertion_params(principal)), wire.dpop(nil, principal.key))
|
|
159
|
+
end
|
|
143
160
|
|
|
144
161
|
def unpaid = Wire.new(base_url:)
|
|
145
162
|
|
|
163
|
+
# The principal's token and key, which the wire signs a DPoP proof with.
|
|
164
|
+
def credential(principal) = wire.dpop(principal.token, principal.key)
|
|
165
|
+
|
|
146
166
|
def setup_required?(answer)
|
|
147
167
|
answer.status == 402 && answer.body.is_a?(Hash) && answer.body['code'] == 'payment_setup_required'
|
|
148
168
|
end
|
|
@@ -40,10 +40,8 @@ module Kiosk
|
|
|
40
40
|
class Customer
|
|
41
41
|
attr_reader :principal
|
|
42
42
|
|
|
43
|
-
# An assistant
|
|
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
|
|
43
|
+
# An assistant that registered its key here and acts for no person yet.
|
|
44
|
+
def self.newcomer(assistant) = new(assistant, assistant.register_client!)
|
|
47
45
|
|
|
48
46
|
def initialize(assistant, principal)
|
|
49
47
|
@assistant = assistant
|
|
@@ -60,15 +58,8 @@ module Kiosk
|
|
|
60
58
|
|
|
61
59
|
def does(action, headers: {}, **args) = Answer.new(@assistant.run(principal, name: action.to_s, headers:, **args))
|
|
62
60
|
|
|
63
|
-
# Signs
|
|
64
|
-
def pays(
|
|
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
|
|
61
|
+
# Signs the checkout and payment mandates for the `checkout_jwt` an action answered, and pays.
|
|
62
|
+
def pays(checkout_jwt) = Answer.new(@assistant.pay(principal, checkout_jwt))
|
|
72
63
|
|
|
73
64
|
# The answer says whether a card is on file, or carries the page where the person saves one.
|
|
74
65
|
def sets_up_payment = does(:payment_setup)
|
|
@@ -92,21 +83,25 @@ module Kiosk
|
|
|
92
83
|
def account = claims['sub']
|
|
93
84
|
def role = claims['role']
|
|
94
85
|
|
|
95
|
-
|
|
86
|
+
# Asks the token endpoint for a new token with the key it holds.
|
|
87
|
+
def signs_back_in = Answer.new(@assistant.token(principal))
|
|
96
88
|
|
|
97
|
-
# This assistant holding the credential a fresh
|
|
89
|
+
# This assistant holding the credential a fresh token request issues.
|
|
98
90
|
def with_a_fresh_credential = holding(signs_back_in)
|
|
99
91
|
|
|
100
92
|
# Redeems the link code a person handed over; the assistant then acts for that person.
|
|
101
|
-
def redeems(link_code)
|
|
93
|
+
def redeems(link_code)
|
|
94
|
+
claimed = Answer.new(@assistant.claim(link_code, principal))
|
|
95
|
+
raise "#{origin} did not redeem #{link_code}: #{claimed}" unless claimed.ok?
|
|
102
96
|
|
|
103
|
-
|
|
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))
|
|
97
|
+
with_a_fresh_credential
|
|
106
98
|
end
|
|
107
99
|
|
|
100
|
+
# Asks to be linked to a person's account; the answer carries the code the person approves.
|
|
101
|
+
def asks_to_be_linked = Answer.new(@assistant.device_authorization(principal))
|
|
102
|
+
|
|
108
103
|
# Asks once whether the person has approved `request`.
|
|
109
|
-
def polls(request) = Answer.new(@assistant.device_token(request['device_code'], principal
|
|
104
|
+
def polls(request) = Answer.new(@assistant.device_token(request['device_code'], principal))
|
|
110
105
|
|
|
111
106
|
# This assistant holding the credential the person granted through `request`.
|
|
112
107
|
def collects(request) = holding(polls(request))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'jwt'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require 'uri'
|
|
9
|
+
require 'kiosk/server/eddsa'
|
|
10
|
+
|
|
11
|
+
module Kiosk
|
|
12
|
+
module TestHelpers
|
|
13
|
+
# The two headers of a call that carries an access token (RFC 9449): the
|
|
14
|
+
# token as `Authorization: DPoP`, and a proof that the token's key signs
|
|
15
|
+
# for this method and URL. A token request has no token yet: it sends
|
|
16
|
+
# only the proof, with no `ath`.
|
|
17
|
+
#
|
|
18
|
+
# Dpop.headers(token, key, method: 'GET', url: 'https://shop.example/kiosk/catalog')
|
|
19
|
+
# Dpop.headers(nil, key, method: 'POST', url: 'https://shop.example/kiosk/oauth/token')
|
|
20
|
+
module Dpop
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# A new assistant key: Ed25519, or P-256 for `ES256`.
|
|
24
|
+
def generate_key(algorithm = 'EdDSA')
|
|
25
|
+
algorithm == 'ES256' ? OpenSSL::PKey::EC.generate('prime256v1') : OpenSSL::PKey.generate_key('ED25519')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The JOSE algorithm the key signs with: EdDSA or ES256.
|
|
29
|
+
def algorithm(key) = Kiosk::Server::Jwk.algorithm(key)
|
|
30
|
+
|
|
31
|
+
def headers(token, key, method:, url:, iat: Time.now.to_i)
|
|
32
|
+
proof = { 'DPoP' => proof(token, key, method:, url:, iat:) }
|
|
33
|
+
token ? { 'Authorization' => "DPoP #{token}", **proof } : proof
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The same two headers as Rack env entries.
|
|
37
|
+
def env(token, key, method:, url:, iat: Time.now.to_i)
|
|
38
|
+
{ 'HTTP_AUTHORIZATION' => "DPoP #{token}", 'HTTP_DPOP' => proof(token, key, method:, url:, iat:) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `url` loses its query and fragment: `htu` names the resource, not the arguments.
|
|
42
|
+
def proof(token, key, method:, url:, iat: Time.now.to_i)
|
|
43
|
+
uri = URI(url.to_s)
|
|
44
|
+
htu = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}#{uri.path}"
|
|
45
|
+
payload = { jti: SecureRandom.uuid, htm: method.to_s.upcase, htu: htu, iat: iat }
|
|
46
|
+
payload[:ath] = ath(token) if token
|
|
47
|
+
JWT.encode(payload, key, algorithm(key), { typ: 'dpop+jwt', jwk: jwk(key) })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ath(token) = Base64.urlsafe_encode64(Digest::SHA256.digest(token.to_s), padding: false)
|
|
51
|
+
|
|
52
|
+
# The RFC 7638 thumbprint of the key: a token's `cnf.jkt`, a rotation's `new_key`.
|
|
53
|
+
def thumbprint(key) = Kiosk::Server::Jwk.thumbprint(key)
|
|
54
|
+
|
|
55
|
+
# The public JWK (RFC 7517) of the key: a proof's `jwk`, a registration's `jwks` member.
|
|
56
|
+
def jwk(key) = Kiosk::Server::Jwk.public_members(key)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -9,6 +9,7 @@ require 'rack/mock'
|
|
|
9
9
|
require 'uri'
|
|
10
10
|
require 'kiosk/server'
|
|
11
11
|
require 'kiosk/test_helpers/wire'
|
|
12
|
+
require 'kiosk/test_helpers/dpop'
|
|
12
13
|
|
|
13
14
|
module Kiosk
|
|
14
15
|
module TestHelpers
|
|
@@ -24,10 +25,10 @@ module Kiosk
|
|
|
24
25
|
|
|
25
26
|
# The verb's answer, or the wire error its refusal names.
|
|
26
27
|
def call(name, kind:, params:, identity:)
|
|
27
|
-
|
|
28
|
-
response = request(name, kind, params,
|
|
28
|
+
credential = credential_for(identity) if identity
|
|
29
|
+
response = request(name, kind, params, credential)
|
|
29
30
|
challenges = toll_challenges(response)
|
|
30
|
-
response = request(name, kind, params,
|
|
31
|
+
response = request(name, kind, params, credential, Wire.solve(challenges)) if challenges
|
|
31
32
|
answer(response)
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -35,27 +36,40 @@ module Kiosk
|
|
|
35
36
|
|
|
36
37
|
def app = @app || ::Rails.application
|
|
37
38
|
|
|
38
|
-
def request(name, kind, params,
|
|
39
|
-
|
|
39
|
+
def request(name, kind, params, credential, proofs = nil)
|
|
40
|
+
query = kind.to_sym == :query
|
|
40
41
|
url = "#{Kiosk.current_issuer.to_s.chomp('/')}#{Kiosk.configuration.mount_path}/#{name}"
|
|
41
|
-
|
|
42
|
+
env = credentials(credential, query ? 'GET' : 'POST', url, proofs)
|
|
43
|
+
return Rack::MockRequest.new(app).get(query_url(url, params), env) if query
|
|
42
44
|
|
|
43
45
|
Rack::MockRequest.new(app).post(url, env.merge(input: JSON.generate(params), 'CONTENT_TYPE' => 'application/json'))
|
|
44
46
|
end
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
# The access token with a DPoP proof its key signs for this request.
|
|
49
|
+
def credentials(credential, method, url, proofs)
|
|
50
|
+
env = credential ? Dpop.env(credential.token, credential.key, method:, url:) : {}
|
|
51
|
+
env['HTTP_KIOSK_POW'] = JSON.generate(proofs) if proofs
|
|
52
|
+
env
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
def query_url(url, params) = params.empty? ? url : "#{url}?#{URI.encode_www_form(params)}"
|
|
52
56
|
|
|
53
|
-
# The token of an assistant linked to the principal for this call.
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
# The token and key of an assistant registered and linked to the principal for this call.
|
|
58
|
+
def credential_for(identity)
|
|
59
|
+
key = Dpop.generate_key
|
|
60
|
+
client = Kiosk::Server::AccountBinding.bind!(agent_id: register(key).id, user_id: identity.user_id,
|
|
61
|
+
requested_role: identity.role)
|
|
62
|
+
token = Kiosk::Server::AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: client.id, role: client.role)
|
|
63
|
+
Wire::DpopCredential.new(token:, key:)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Registers the key as a client, and pays the registration toll if the origin asks for one.
|
|
67
|
+
def register(key)
|
|
68
|
+
metadata = { 'jwks' => { 'keys' => [Kiosk::Server::AssistantKey.to_jwk(key)] } }
|
|
69
|
+
Kiosk::Server::ClientRegistration.call(metadata)
|
|
70
|
+
rescue Kiosk::Server::Errors::PowRequired => e
|
|
71
|
+
header = JSON.generate(Wire.solve(JSON.parse(JSON.generate(e.challenges))))
|
|
72
|
+
Kiosk::Server::ClientRegistration.call(metadata, pow: Kiosk::Server::PowGate.proofs_from_header(header))
|
|
59
73
|
end
|
|
60
74
|
|
|
61
75
|
def toll_challenges(response)
|
|
@@ -24,7 +24,7 @@ module Kiosk
|
|
|
24
24
|
class Provider < Kiosk::KycProviders::Base
|
|
25
25
|
Check = Data.define(:subject, :audience, :nonce, :callback_url)
|
|
26
26
|
|
|
27
|
-
def self.key = @key ||= OpenSSL::PKey
|
|
27
|
+
def self.key = @key ||= OpenSSL::PKey.generate_key('ED25519')
|
|
28
28
|
|
|
29
29
|
def initialize
|
|
30
30
|
super
|
|
@@ -47,7 +47,7 @@ module Kiosk
|
|
|
47
47
|
def attest(subject, audience, attributes)
|
|
48
48
|
now = Time.now.to_i
|
|
49
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, '
|
|
50
|
+
iat: now, exp: now + 3600 }, self.class.key, 'EdDSA')
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
@@ -87,7 +87,7 @@ module Kiosk
|
|
|
87
87
|
@kyc_provider = Provider.new
|
|
88
88
|
config.kyc_provider = @kyc_provider
|
|
89
89
|
config.kyc_issuer = ISSUER
|
|
90
|
-
config.kyc_public_key = Provider.key
|
|
90
|
+
config.kyc_public_key = Provider.key
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
def restore_kyc_provider
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'puma'
|
|
4
3
|
require 'socket'
|
|
4
|
+
begin
|
|
5
|
+
require 'puma'
|
|
6
|
+
rescue LoadError
|
|
7
|
+
raise LoadError, "Kiosk::TestHelpers::LiveServer serves the app with Puma. Add gem 'puma' to the Gemfile."
|
|
8
|
+
end
|
|
5
9
|
require 'kiosk/test_helpers/assistant'
|
|
6
10
|
|
|
7
11
|
module Kiosk
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'jwt'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require 'kiosk/test_helpers/dpop'
|
|
9
|
+
|
|
10
|
+
module Kiosk
|
|
11
|
+
module TestHelpers
|
|
12
|
+
# The two closed AP2 v0.2 mandates an assistant signs to pay an operator's
|
|
13
|
+
# checkout, as compact SD-JWTs: what an assistant following the skill sends.
|
|
14
|
+
module Mandate
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param checkout_jwt [String] the checkout an action answered, unchanged
|
|
18
|
+
# @return [Hash] `checkout:` and `payment:`, each a compact SD-JWT
|
|
19
|
+
def pay(key, checkout_jwt, instrument: 'on_file', lifetime: 600)
|
|
20
|
+
checkout = JWT.decode(checkout_jwt, nil, false).first
|
|
21
|
+
checkout_hash = digest(checkout_jwt)
|
|
22
|
+
now = Time.now.to_i
|
|
23
|
+
times = { iat: now, exp: now + lifetime }
|
|
24
|
+
{ checkout: sign(key, { vct: 'mandate.checkout.1', checkout_jwt:, checkout_hash:, **times }),
|
|
25
|
+
payment: sign(key, { vct: 'mandate.payment.1', transaction_id: checkout_hash, payee: checkout['merchant'],
|
|
26
|
+
payment_amount: { amount: checkout['total_amount_cents'], currency: checkout['currency'] },
|
|
27
|
+
payment_instrument: { id: instrument, type: 'card' }, **times }) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A closed mandate: an SD-JWT whose one disclosure is `content`.
|
|
31
|
+
def sign(key, content)
|
|
32
|
+
disclosure = b64(JSON.generate([b64(SecureRandom.random_bytes(16)), content]))
|
|
33
|
+
jwt = JWT.encode({ _sd_alg: 'sha-256', delegate_payload: [{ '...' => digest(disclosure) }] }, key, Dpop.algorithm(key),
|
|
34
|
+
{ typ: 'dc+sd-jwt' })
|
|
35
|
+
"#{jwt}~#{disclosure}~"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The disclosed mandate of `sd_jwt`, unverified.
|
|
39
|
+
def content(sd_jwt) = JSON.parse(Base64.urlsafe_decode64(sd_jwt.split('~')[1])).last
|
|
40
|
+
|
|
41
|
+
def digest(text) = b64(Digest::SHA256.digest(text))
|
|
42
|
+
|
|
43
|
+
def b64(bytes) = Base64.urlsafe_encode64(bytes, padding: false)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# The solver for a proof-of-work challenge. kiosk-pow-equihash ships the one
|
|
6
|
+
# solver here, and the host's bundle brings that gem.
|
|
7
|
+
module PowSolver
|
|
8
|
+
def self.for(challenge)
|
|
9
|
+
alg = challenge['alg']
|
|
10
|
+
raise ArgumentError, "The origin asks for a #{alg.inspect} proof of work. Wire solves only Equihash." unless alg == 'equihash'
|
|
11
|
+
|
|
12
|
+
require 'kiosk/pow/equihash/solver'
|
|
13
|
+
Kiosk::Pow::Equihash
|
|
14
|
+
rescue LoadError
|
|
15
|
+
raise LoadError, "Wire solves Equihash tolls with kiosk-pow-equihash. Add gem 'kiosk-pow-equihash' to the Gemfile."
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -18,7 +18,7 @@ module Kiosk
|
|
|
18
18
|
# A newly registered assistant, as `as` (a demo's own Customer subclass).
|
|
19
19
|
def a_customer(as: Customer) = as.new(assistant, register)
|
|
20
20
|
|
|
21
|
-
# An assistant
|
|
21
|
+
# An assistant that registered its key here and acts for no person yet.
|
|
22
22
|
def a_newcomer(as: Customer) = as.newcomer(assistant)
|
|
23
23
|
|
|
24
24
|
# Someone with an account on the operator's site, signed in there.
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'net/http'
|
|
5
5
|
require 'uri'
|
|
6
|
-
require 'kiosk/
|
|
6
|
+
require 'kiosk/test_helpers/dpop'
|
|
7
|
+
require 'kiosk/test_helpers/pow_solver'
|
|
7
8
|
|
|
8
9
|
module Kiosk
|
|
9
10
|
module TestHelpers
|
|
@@ -11,7 +12,7 @@ module Kiosk
|
|
|
11
12
|
# `pay_tolls: true` it solves a proof-of-work toll and sends the request once more.
|
|
12
13
|
#
|
|
13
14
|
# wire = Kiosk::TestHelpers::Wire.new(base_url: "http://127.0.0.1:3006")
|
|
14
|
-
# status, problem = wire.post_json("/kiosk/edit_listing", { listing_id: "junk" },
|
|
15
|
+
# status, problem = wire.post_json("/kiosk/edit_listing", { listing_id: "junk" }, wire.dpop(token, key))
|
|
15
16
|
class Wire
|
|
16
17
|
# One answer: a body that is not JSON reads as `{}`, an unreachable origin as status 0.
|
|
17
18
|
Response = Data.define(:status, :headers, :body, :raw_body, :proofs) do
|
|
@@ -42,10 +43,15 @@ module Kiosk
|
|
|
42
43
|
http
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
# An access token and the key it is bound to. As a header value, the wire
|
|
47
|
+
# sends it as `Authorization: DPoP <token>` with a proof for that request;
|
|
48
|
+
# with no token, as the proof alone (a token request).
|
|
49
|
+
DpopCredential = Data.define(:token, :key)
|
|
50
|
+
|
|
51
|
+
def self.dpop(token, key) = { 'Authorization' => DpopCredential.new(token:, key:) }
|
|
46
52
|
|
|
47
53
|
# A solved proof of work for each challenge, as the `Kiosk-PoW` header carries them.
|
|
48
|
-
def self.solve(challenges) = challenges.map { { challenge: it, nonce:
|
|
54
|
+
def self.solve(challenges) = challenges.map { { challenge: it, nonce: PowSolver.for(it).solve(it) } }
|
|
49
55
|
|
|
50
56
|
attr_reader :base_url
|
|
51
57
|
|
|
@@ -56,7 +62,7 @@ module Kiosk
|
|
|
56
62
|
@read_timeout = read_timeout
|
|
57
63
|
end
|
|
58
64
|
|
|
59
|
-
def
|
|
65
|
+
def dpop(token, key) = self.class.dpop(token, key)
|
|
60
66
|
|
|
61
67
|
def post_json(path, body = {}, headers = {}) = post(path, body, headers).then { [it.status, it.body] }
|
|
62
68
|
|
|
@@ -97,10 +103,8 @@ module Kiosk
|
|
|
97
103
|
challenges if challenges.is_a?(Array) && challenges.any?
|
|
98
104
|
end
|
|
99
105
|
|
|
100
|
-
def deliver(klass, uri, body, headers)
|
|
101
|
-
request = klass
|
|
102
|
-
request.body = JSON.generate(body) unless body.nil?
|
|
103
|
-
yield request if block_given?
|
|
106
|
+
def deliver(klass, uri, body, headers, &)
|
|
107
|
+
request = build(klass, uri, body, headers, &)
|
|
104
108
|
begin
|
|
105
109
|
answer = self.class.http_for(uri, open_timeout: @open_timeout, read_timeout: @read_timeout).request(request)
|
|
106
110
|
rescue StandardError => e
|
|
@@ -109,6 +113,25 @@ module Kiosk
|
|
|
109
113
|
response(answer)
|
|
110
114
|
end
|
|
111
115
|
|
|
116
|
+
def build(klass, uri, body, headers)
|
|
117
|
+
headers = { 'Content-Type' => 'application/json' }.merge(headers) unless body.nil?
|
|
118
|
+
request = klass.new(uri, signed(headers, klass::METHOD, uri))
|
|
119
|
+
request.body = JSON.generate(body) unless body.nil?
|
|
120
|
+
yield request if block_given?
|
|
121
|
+
request
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Each delivery signs its own DPoP proof, so a retry never reuses one.
|
|
125
|
+
def signed(headers, method, uri)
|
|
126
|
+
headers.each_with_object({}) do |(name, value), out|
|
|
127
|
+
if value.is_a?(DpopCredential)
|
|
128
|
+
out.merge!(Dpop.headers(value.token, value.key, method:, url: uri.to_s))
|
|
129
|
+
else
|
|
130
|
+
out[name] = value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
112
135
|
def response(answer)
|
|
113
136
|
raw_body = answer.body.to_s
|
|
114
137
|
body = begin
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kiosk-test-support
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Phil Pirozhkov
|
|
@@ -35,7 +35,7 @@ dependencies:
|
|
|
35
35
|
requirements:
|
|
36
36
|
- - ">="
|
|
37
37
|
- !ruby/object:Gem::Version
|
|
38
|
-
version: '2.
|
|
38
|
+
version: '2.10'
|
|
39
39
|
- - "<"
|
|
40
40
|
- !ruby/object:Gem::Version
|
|
41
41
|
version: '4.0'
|
|
@@ -45,7 +45,7 @@ dependencies:
|
|
|
45
45
|
requirements:
|
|
46
46
|
- - ">="
|
|
47
47
|
- !ruby/object:Gem::Version
|
|
48
|
-
version: '2.
|
|
48
|
+
version: '2.10'
|
|
49
49
|
- - "<"
|
|
50
50
|
- !ruby/object:Gem::Version
|
|
51
51
|
version: '4.0'
|
|
@@ -55,42 +55,42 @@ dependencies:
|
|
|
55
55
|
requirements:
|
|
56
56
|
- - "~>"
|
|
57
57
|
- !ruby/object:Gem::Version
|
|
58
|
-
version: 0.
|
|
58
|
+
version: 0.6.0
|
|
59
59
|
type: :runtime
|
|
60
60
|
prerelease: false
|
|
61
61
|
version_requirements: !ruby/object:Gem::Requirement
|
|
62
62
|
requirements:
|
|
63
63
|
- - "~>"
|
|
64
64
|
- !ruby/object:Gem::Version
|
|
65
|
-
version: 0.
|
|
65
|
+
version: 0.6.0
|
|
66
66
|
- !ruby/object:Gem::Dependency
|
|
67
|
-
name: kiosk-
|
|
67
|
+
name: kiosk-server
|
|
68
68
|
requirement: !ruby/object:Gem::Requirement
|
|
69
69
|
requirements:
|
|
70
70
|
- - "~>"
|
|
71
71
|
- !ruby/object:Gem::Version
|
|
72
|
-
version: 0.
|
|
72
|
+
version: 0.6.0
|
|
73
73
|
type: :runtime
|
|
74
74
|
prerelease: false
|
|
75
75
|
version_requirements: !ruby/object:Gem::Requirement
|
|
76
76
|
requirements:
|
|
77
77
|
- - "~>"
|
|
78
78
|
- !ruby/object:Gem::Version
|
|
79
|
-
version: 0.
|
|
79
|
+
version: 0.6.0
|
|
80
80
|
- !ruby/object:Gem::Dependency
|
|
81
|
-
name:
|
|
81
|
+
name: kiosk-user-idp-devise
|
|
82
82
|
requirement: !ruby/object:Gem::Requirement
|
|
83
83
|
requirements:
|
|
84
|
-
- - "
|
|
84
|
+
- - "~>"
|
|
85
85
|
- !ruby/object:Gem::Version
|
|
86
|
-
version:
|
|
86
|
+
version: 0.6.0
|
|
87
87
|
type: :runtime
|
|
88
88
|
prerelease: false
|
|
89
89
|
version_requirements: !ruby/object:Gem::Requirement
|
|
90
90
|
requirements:
|
|
91
|
-
- - "
|
|
91
|
+
- - "~>"
|
|
92
92
|
- !ruby/object:Gem::Version
|
|
93
|
-
version:
|
|
93
|
+
version: 0.6.0
|
|
94
94
|
- !ruby/object:Gem::Dependency
|
|
95
95
|
name: websocket-driver
|
|
96
96
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -130,10 +130,12 @@ description: |
|
|
|
130
130
|
mandates and listens on the event stream. `Kyc` stands in for the
|
|
131
131
|
operator's KYC provider, and `StripeMock` fronts a local stripe-mock.
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
`InProcessExecutor`, `InProcessWire` and `Conformance::InProcessOrigin`
|
|
134
|
+
run against kiosk-server, and `Person` signs in through
|
|
135
|
+
kiosk-user-idp-devise; both are dependencies. The host's bundle brings
|
|
136
|
+
the rest: the Minitest and RSpec adapters use the test framework the
|
|
137
|
+
host runs, `LiveServer` serves the app with Puma, and `Wire` solves
|
|
138
|
+
Equihash tolls with kiosk-pow-equihash.
|
|
137
139
|
email:
|
|
138
140
|
- hello@fili.pp.ru
|
|
139
141
|
executables: []
|
|
@@ -163,14 +165,17 @@ files:
|
|
|
163
165
|
- lib/kiosk/test_helpers/conformance/verb.rb
|
|
164
166
|
- lib/kiosk/test_helpers/customer.rb
|
|
165
167
|
- lib/kiosk/test_helpers/descriptor_examples.rb
|
|
168
|
+
- lib/kiosk/test_helpers/dpop.rb
|
|
166
169
|
- lib/kiosk/test_helpers/errors.rb
|
|
167
170
|
- lib/kiosk/test_helpers/in_process_executor.rb
|
|
168
171
|
- lib/kiosk/test_helpers/in_process_wire.rb
|
|
169
172
|
- lib/kiosk/test_helpers/journey.rb
|
|
170
173
|
- lib/kiosk/test_helpers/kyc.rb
|
|
171
174
|
- lib/kiosk/test_helpers/live_server.rb
|
|
175
|
+
- lib/kiosk/test_helpers/mandate.rb
|
|
172
176
|
- lib/kiosk/test_helpers/null_executor.rb
|
|
173
177
|
- lib/kiosk/test_helpers/person.rb
|
|
178
|
+
- lib/kiosk/test_helpers/pow_solver.rb
|
|
174
179
|
- lib/kiosk/test_helpers/small_equihash.rb
|
|
175
180
|
- lib/kiosk/test_helpers/story.rb
|
|
176
181
|
- lib/kiosk/test_helpers/stripe_mock.rb
|