kiosk-test-support 0.5.20 → 0.6.28

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82f1f47c2715da7a7ef6897038c156d7033991f159d7dea50446ee3af8933d01
4
- data.tar.gz: 0b06db9324f1ea8d0e7e11036f756db6e4eef3fffc9899961506e95f0f999183
3
+ metadata.gz: c1a0b3e7ba79f9c1cf3f0e0ed29b2d9c7ed3deb87eed7deae9e030637d177903
4
+ data.tar.gz: 9df61c38d3daefdc760b862cdc84e071e049ee0753172dde44877585e9949161
5
5
  SHA512:
6
- metadata.gz: 4b1c2beec651bb6bb2836c28cb5440903d113867d91f33bce3bdd42625c5d34336b0916332e39b3d92b60d5633668074decb3c5c2169597fd6c036c7b274595f
7
- data.tar.gz: 66c9376f8e27a37bd455f503fa34f1617d8e7e790eb348fc44075494cc81e386f9e1ec23593a86f011781ec537909ec5a14a2feba01f7cb4fa4cee1684ccbbc9
6
+ metadata.gz: dc68223d652f7b3363e8368f1651a98b9c9bc3b52ef6f67e46edc752267348df2269790975a21dc9f72cd3066db4ebab33569b812b43ce66562fbfb3c0782f4e
7
+ data.tar.gz: 1e1d81c8ad29a55264b2d3b4e20a56ea6a27b7b90c0eff079680396158fb863c1f67dec1db3db7fc129edbed2c8191b06fd64bf21b5bb7d6dd0f81b1657eda02
data/CHANGELOG.md CHANGED
@@ -12,6 +12,161 @@ already written is edited.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.6.28] — 2026-10-26
16
+
17
+ - 2026-10-26: Version 0.6.28, the tree cut that matches skill 0.6.28.
18
+
19
+ ## [0.6.27] — 2026-10-26
20
+
21
+ - 2026-10-26: Version 0.6.27, the tree cut that matches skill 0.6.27.
22
+ ## [0.6.26] — 2026-10-26
23
+
24
+ - 2026-10-26: Version 0.6.26, the tree cut that matches skill 0.6.26.
25
+ ## [0.6.25] — 2026-10-26
26
+
27
+ - 2026-10-26: Version 0.6.25, the tree cut that matches skill 0.6.25.
28
+ - 2026-10-26: The README DSL example seeds, then reads, and says that the assistant helpers call the default mount path `/kiosk`.
29
+
30
+ ## [0.6.24] — 2026-10-26
31
+
32
+ - 2026-10-26: Version 0.6.24, the tree cut that matches skill 0.6.24.
33
+
34
+ ## [0.6.23] — 2026-10-26
35
+
36
+ - 2026-10-26: Version 0.6.23, the tree cut that matches skill 0.6.23.
37
+
38
+ ## [0.6.22] — 2026-10-26
39
+
40
+ - 2026-10-26: Version 0.6.22, the tree cut that matches skill 0.6.22.
41
+
42
+ ## [0.6.21] — 2026-10-26
43
+
44
+ - 2026-10-26: Version 0.6.21, the tree cut that matches skill 0.6.21.
45
+
46
+ ## [0.6.20] — 2026-10-26
47
+
48
+ - 2026-10-26: Version 0.6.20, the tree cut that matches skill 0.6.20.
49
+
50
+ ## [0.6.19] — 2026-10-26
51
+
52
+ - 2026-10-26: Version 0.6.19, the tree cut that matches skill 0.6.19.
53
+
54
+ ## [0.6.18] — 2026-10-25
55
+
56
+ - 2026-10-25: Version 0.6.18, the tree cut that matches skill 0.6.18.
57
+ - 2026-10-25: The gem description and README say that the helpers solve proof-of-work tolls.
58
+
59
+ ## [0.6.17] — 2026-10-25
60
+
61
+ - 2026-10-25: Version 0.6.17, the tree cut that matches skill 0.6.17.
62
+
63
+ ## [0.6.16] — 2026-10-25
64
+
65
+ - 2026-10-25: Version 0.6.16, the tree cut that matches skill 0.6.16.
66
+ - `SeededDatabase` empties every table on the search path but Rails' own two.
67
+
68
+ ## [0.6.15] — 2026-10-25
69
+
70
+ - 2026-10-25: Version 0.6.15, the tree cut that matches skill 0.6.15.
71
+
72
+ - 2026-10-25: `Kiosk::TestHelpers::SkillPin` (Minitest and RSpec): fails when kiosk-server gives a skill that `skill_sha256` is not the digest of, and tells how to make the new digest.
73
+
74
+ ## [0.6.14] — 2026-10-25
75
+
76
+ - 2026-10-25: Version 0.6.14, the tree cut that matches skill 0.6.14.
77
+
78
+ ## [0.6.13] — 2026-10-25
79
+
80
+ - 2026-10-25: Version 0.6.13, the tree cut that matches skill 0.6.13.
81
+
82
+ ## [0.6.12] — 2026-10-24
83
+
84
+ - 2026-10-24: Version 0.6.12, the tree cut that matches skill 0.6.12.
85
+
86
+ - 2026-10-24: The KYC stand-in replaces `c.kyc_provider` only, and keeps the audience of the provider it stands in for.
87
+
88
+ ## [0.6.11] — 2026-10-24
89
+
90
+ - 2026-10-24: Version 0.6.11, the tree cut that matches skill 0.6.11.
91
+
92
+ ## [0.6.10] — 2026-10-23
93
+
94
+ - 2026-10-23: Version 0.6.10, the tree cut that matches skill 0.6.10.
95
+
96
+ ## [0.6.9] — 2026-10-23
97
+
98
+ - 2026-10-23: Version 0.6.9, the tree cut that matches skill 0.6.9.
99
+
100
+ ## [0.6.8] — 2026-10-22
101
+
102
+ - 2026-10-22: Version 0.6.8, the tree cut that matches skill 0.6.8.
103
+
104
+ ## [0.6.7] — 2026-10-22
105
+
106
+ - 2026-10-22: Version 0.6.7, the tree cut that matches skill 0.6.7.
107
+
108
+ ## [0.6.6] — 2026-10-22
109
+
110
+ - 2026-10-22: Version 0.6.6, the tree cut that matches skill 0.6.6.
111
+
112
+ ## [0.6.5] — 2026-10-22
113
+
114
+ - 2026-10-22: Version 0.6.5, the tree cut that matches skill 0.6.5.
115
+
116
+ ## [0.6.4] — 2026-10-22
117
+
118
+ - 2026-10-22: Version 0.6.4, the tree cut that matches skill 0.6.4.
119
+
120
+ ## [0.6.3] — 2026-10-22
121
+
122
+ - 2026-10-22: Version 0.6.3, the tree cut that matches skill 0.6.3.
123
+
124
+ ## [0.6.2] — 2026-10-22
125
+
126
+ - 2026-10-22: Version 0.6.2, the tree cut that matches skill 0.6.2.
127
+
128
+ ## [0.6.1] — 2026-10-22
129
+
130
+ - 2026-10-22: Version 0.6.1, the tree cut that matches skill 0.6.1.
131
+ - 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.
132
+
133
+ ## [0.6.0] — 2026-10-21
134
+
135
+ - 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.
136
+ - 2026-10-21: Version 0.6.0, the tree cut that matches skill 0.6.0.
137
+
138
+ ## [0.5.27] — 2026-10-21
139
+
140
+ - 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.
141
+ - 2026-10-21: Version 0.5.27, the tree cut that matches skill 0.5.27.
142
+
143
+ ## [0.5.26] — 2026-10-21
144
+
145
+ - 2026-10-21: Version 0.5.26, the tree cut that matches skill 0.5.26.
146
+
147
+ ## [0.5.25] — 2026-10-21
148
+
149
+ - 2026-10-21: Version 0.5.25, the tree cut that matches skill 0.5.25.
150
+
151
+ ## [0.5.24] — 2026-10-21
152
+
153
+ - 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.
154
+ - 2026-10-21: Version 0.5.24, the tree cut that matches skill 0.5.24.
155
+
156
+ ## [0.5.23] — 2026-10-21
157
+
158
+ - 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.
159
+ - 2026-10-21: Version 0.5.23, the tree cut that matches skill 0.5.23.
160
+
161
+ ## [0.5.22] — 2026-10-21
162
+
163
+ - 2026-10-21: `Kiosk::TestHelpers::Mandate` signs the two AP2 v0.2 mandates; `Assistant#pay` and `Customer#pays` take a `checkout_jwt`.
164
+ - 2026-10-21: Version 0.5.22, the tree cut that matches skill 0.5.22.
165
+
166
+ ## [0.5.21] — 2026-10-21
167
+
168
+ - 2026-10-21: Version 0.5.21, the tree cut that matches skill 0.5.21.
169
+
15
170
  ## [0.5.20] — 2026-10-20
16
171
 
17
172
  - 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
@@ -4,52 +4,57 @@ Test helpers for [Kiosk](https://kiosk.tech) origins, and the journey-test DSL o
4
4
 
5
5
  ## What it does
6
6
 
7
- - `Kiosk::TestHelpers::Assistant` — registers, pays tolls, queries, runs actions, pays and listens for events against an origin, as an AI assistant does.
8
- - `Kiosk::TestHelpers::Wire` — the same transport without a principal: any method, any path, any headers.
7
+ - `Kiosk::TestHelpers::Assistant` — registers, solves tolls, queries, runs actions, pays and listens for events against an origin, as an AI assistant does.
8
+ - `Kiosk::TestHelpers::Wire` — the same transport without an assistant's credential: any method, any path, any headers.
9
9
  - `Kiosk::StoryTest` / `Kiosk::TestHelpers::Story` — a test told as a business story by customers' assistants and the people on the operator's site.
10
10
  - `Kiosk::TestHelpers::Kyc` — stands in for the operator's KYC provider, so a test decides how an identity check ends.
11
11
  - `Kiosk::TestHelpers::StripeMock` — starts a local stripe-mock that answers a confirmed charge as paid.
12
12
  - `Kiosk::TestHelpers::LiveServer` — serves the app over HTTP from the test process and gives the test an assistant for it.
13
13
  - `Kiosk::TestHelpers::SeededDatabase` — each test starts from the seeds and leaves the database empty.
14
14
  - `Kiosk::TestHelpers::DescriptorExamples` — the examples a served `/kiosk/schema` publishes, each checked against its schema.
15
+ - `Kiosk::TestHelpers::SkillPin` — fails when an update of kiosk-server gives a skill that your `skill_sha256` is not the digest of.
15
16
 
16
17
  Carries the shared pieces of the Kiosk journey-test DSL: the `Journey` module mixed into RSpec / Minitest tests, the pluggable `executor` contract, a `NullExecutor` for self-tests, and the structured error classes that the framework-specific matchers and assertions look for.
17
18
 
18
19
  The journey DSL comes with one of the harnesses:
19
20
 
20
- > **Not on RubyGems yet** — so every `gem` line below carries `github: "kiosk-hq/kiosk"`, which is what makes it copy-pasteable today. Publication status and the canonical install are stated once, in the monorepo README's [Install](https://github.com/kiosk-hq/kiosk#install) section.
21
-
22
21
  ```ruby
23
22
  group :test do
24
- gem "kiosk-rls-rspec", github: "kiosk-hq/kiosk" # RSpec
23
+ gem "kiosk-rls-rspec" # RSpec
25
24
  # or
26
- gem "kiosk-rls-minitest", github: "kiosk-hq/kiosk" # Minitest
25
+ gem "kiosk-rls-minitest" # Minitest
27
26
  end
28
27
  ```
29
28
 
30
29
  Both harnesses pull `kiosk-test-support` as a transitive dependency.
31
30
 
31
+ Your bundle brings what the helpers drive: `puma` for `LiveServer` (`rails new` writes it), and `kiosk-pow-equihash` for the solver that solves Equihash tolls. `Wire` solves no other proof of work.
32
+
32
33
  ## DSL
33
34
 
35
+ Each `as_*` scope runs in a transaction that rolls back. `kiosk_seed` writes
36
+ outside the scopes, so a test reads the rows that it seeds. With an `orders`
37
+ table whose policy shows a customer only their own rows (kiosk-rls) and
38
+ `c.enforce_db_role`:
39
+
34
40
  ```ruby
35
- as_agent_of(alice) do
36
- run_action :create_order, items: ["bread"]
37
- end
41
+ kiosk_seed :orders, owner: alice, item: "bread"
38
42
 
39
- as_user(alice) do
43
+ as_agent_of(alice) do
40
44
  expect(query("select item from orders")).to contain_exactly({ item: "bread" })
41
45
  end
42
46
 
43
- as_anonymous do
44
- expect { query("select * from orders") }.to be_rls_denied
47
+ as_agent_of(bob) do
48
+ expect(query("select item from orders")).to be_empty
49
+ expect { query("insert into orders (user_id, item) values ('#{alice.id}', 'tea')") }.to be_rls_denied
45
50
  end
46
51
  ```
47
52
 
48
53
  Helpers: `as_agent_of(user, role:)`, `as_user(user, role:)`, `as_agent(name)`, `as_anonymous`, `query(sql)`, `run_query(name, **args)`, `run_action(name, **args)`, `pay_action(name, **args)`, `kiosk_seed(table, count:, owner:, **attrs)`. See the `Kiosk::TestHelpers::Journey` docstrings for full semantics.
49
54
 
50
- ## Wiring an executor
55
+ ## Configuring an executor
51
56
 
52
- The DSL is inert until you wire an executor. In production-shaped tests you wire `Kiosk::TestHelpers::InProcessExecutor`, which runs against `kiosk-server` in your Rails app:
57
+ The DSL is inert until you configure an executor. In production-shaped tests you configure `Kiosk::TestHelpers::InProcessExecutor`, which runs against `kiosk-server` in your Rails app:
53
58
 
54
59
  ```ruby
55
60
  # spec/spec_helper.rb (RSpec) or test/test_helper.rb (Minitest)
@@ -65,11 +70,11 @@ Kiosk::TestHelpers.executor = Kiosk::TestHelpers::NullExecutor.new.tap do |e|
65
70
  end
66
71
  ```
67
72
 
68
- The `NullExecutor` is the zero-dependency fallback for unit-shaped tests; production-shaped tests wire `Kiosk::TestHelpers::InProcessExecutor` (above).
73
+ The `NullExecutor` is the zero-dependency fallback for unit-shaped tests; production-shaped tests configure `Kiosk::TestHelpers::InProcessExecutor` (above).
69
74
 
70
- ## Driving the wire as an assistant
75
+ ## Driving the protocol as an assistant
71
76
 
72
- `Kiosk::TestHelpers::Assistant` registers, pays proof-of-work tolls, queries, runs, pays with signed mandates and listens on `<endpoint>/events`, against any origin — a test's own live server or a deployment:
77
+ `Kiosk::TestHelpers::Assistant` registers its key as an OAuth client and gets DPoP-bound tokens, solves proof-of-work tolls, queries, runs, pays with closed AP2 mandates (SD-JWT) and listens on `/kiosk/events`. It calls the `/kiosk/...` paths, so it works against an origin that mounts the engine at the default path `/kiosk` — a test's own live server or a deployment. `Person` and the events client call the same paths:
73
78
 
74
79
  ```ruby
75
80
  require "kiosk/test_helpers/assistant"
@@ -81,7 +86,7 @@ events.subscribe("kyc_verification")
81
86
  assistant.run(rider, name: "reserve", scooter_code: "SK-001")
82
87
  ```
83
88
 
84
- Every answer carries its status, parsed body and headers. `Kiosk::TestHelpers::Wire` is the same transport without a principal: any method, any path, any headers.
89
+ Every answer carries its status, parsed body and headers. `Kiosk::TestHelpers::Wire` is the same transport without an assistant's credential: any method, any path, any headers.
85
90
 
86
91
  ## Telling a business story
87
92
 
@@ -90,7 +95,7 @@ Every answer carries its status, parsed body and headers. `Kiosk::TestHelpers::W
90
95
  ```ruby
91
96
  class Shopper < Kiosk::TestHelpers::Customer
92
97
  def orders(*skus) = does(:create_order, items: skus.map { { sku: _1, qty: 1 } })
93
- def pays_for(order) = pays(total: order["total_cents"], scope: "grocery", line_items: [{ order_id: order["order_id"] }])
98
+ def pays_for(order) = pays(order["checkout_jwt"])
94
99
  end
95
100
 
96
101
  class ShopStory < Kiosk::StoryTest
@@ -101,17 +106,17 @@ class ShopStory < Kiosk::StoryTest
101
106
  end
102
107
  ```
103
108
 
104
- - `a_customer(as:)` registers an assistant; `a_newcomer(as:)` holds a key and no account yet; `published(path)` reads what the origin shows anyone.
109
+ - `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
110
  - `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
- - `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(total:, scope:, line_items:, currency:)` signs the intent, cart and payment mandates for a quote and pays; on `payment_setup_required` it calls `payment_setup` and pays once more.
108
- - `Customer#account`, `#role` and `#claims` read its credential; `#signs_back_in`, `#with_a_fresh_credential`, `#redeems(link_code)`, `#asks_to_be_linked`, `#polls` and `#collects` are the sign-in and linking ceremonies.
111
+ - `Kiosk::StoryTest#assert_refused(answer, code)` asserts the protocol refused the `Answer` with `code`.
112
+ - `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.
113
+ - `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
114
  - `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
115
  - `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
116
 
112
117
  ## Identity checks in tests
113
118
 
114
- A real identity check is a human showing documents to a provider; a test cannot do that, so `Kiosk::TestHelpers::Kyc` stands in for the operator's KYC provider for the length of each test and the test says how the check ends. The engine receives the provider's callback and runs the operator's code exactly as it would in production:
119
+ A real identity check is a human showing documents to a KYC provider; a test cannot do that, so `Kiosk::TestHelpers::Kyc` stands in for the operator's KYC provider for the length of each test and the test says how the check ends. The engine receives the provider's callback and runs the operator's code exactly as it would in production:
115
120
 
116
121
  ```ruby
117
122
  require "kiosk/test_helpers/kyc"
@@ -128,13 +133,53 @@ class LicenceStory < Kiosk::StoryTest # or an RSpec group including Kiosk::Tes
128
133
  end
129
134
  ```
130
135
 
131
- `kyc_attestation(principal, **attributes)` signs an attestation an assistant submits to `POST <endpoint>/agents/kyc`. A check the human refuses sends nothing, so there is no helper for it.
136
+ `kyc_attestation(principal, **attributes)` signs an attestation an assistant submits to `POST /kiosk/agents/kyc`. A check the human refuses sends nothing, so there is no helper for it.
132
137
 
133
138
  `Kiosk::TestHelpers::StripeMock.start` fronts a local [stripe-mock](https://github.com/stripe/stripe-mock) that answers a confirmed charge as paid. stripe-mock lists a saved card for every Customer, so once `payment_setup` has mapped one, `pay` settles.
134
139
 
140
+ ## The skill pin
141
+
142
+ kiosk-server gives the URL of the skill that AI assistants read, and each kiosk-server release gives the skill of its own version. You set `skill_sha256`, the SHA-256 of that file, in `config/initializers/kiosk.rb`. This command downloads the file from kiosk.tech and calculates the digest:
143
+
144
+ ```bash
145
+ curl -fsS "$(bin/rails runner 'puts Kiosk.configuration.skill_url')" | shasum -a 256
146
+ ```
147
+
148
+ ```ruby
149
+ c.skill_sha256 = "<the 64 hexadecimal characters that the command prints>"
150
+ ```
151
+
152
+ Then add a test that names the skill version of that digest: `X.Y.Z` from `skill-vX.Y.Z.md`. Minitest:
153
+
154
+ ```ruby
155
+ require "kiosk/test_helpers/skill_pin"
156
+
157
+ class KioskSkillPinTest < ActiveSupport::TestCase
158
+ include Kiosk::TestHelpers::SkillPin::Assertions
159
+
160
+ test "skill_sha256 is the digest of the skill that kiosk-server gives" do
161
+ assert_kiosk_skill_pin hashed_version: "X.Y.Z"
162
+ end
163
+ end
164
+ ```
165
+
166
+ RSpec:
167
+
168
+ ```ruby
169
+ require "kiosk/test_helpers/skill_pin/rspec"
170
+
171
+ RSpec.describe "the skill pin" do
172
+ it "is the digest of the skill that kiosk-server gives" do
173
+ expect(Kiosk.configuration).to pin_the_served_skill(hashed_version: "X.Y.Z")
174
+ end
175
+ end
176
+ ```
177
+
178
+ After an update of kiosk-server to a release with a different skill, the test fails. Its message gives the URL of the new skill, the command that calculates its SHA-256, and the two lines to change. The test also fails when your configuration sets `skill_url`: kiosk-server gives it.
179
+
135
180
  ## Status
136
181
 
137
- Pre-v1.0 alpha. The Journey DSL surface is stable across pre-v1.0 minor bumps; the executor contract may still evolve pre-v1.0 (`Kiosk::TestHelpers::InProcessExecutor` implements it today).
182
+ Pre-v1.0 alpha: the Journey DSL and the executor contract may change between releases. `Kiosk::TestHelpers::InProcessExecutor` implements the contract.
138
183
 
139
184
  ## License
140
185
 
@@ -8,7 +8,7 @@ module Kiosk
8
8
  class StoryTest < ActiveSupport::TestCase
9
9
  include TestHelpers::Story
10
10
 
11
- # Asserts the wire refused `response` with `code`.
11
+ # Asserts the protocol refused `response` with `code`.
12
12
  def assert_refused(response, code)
13
13
  assert response.refused?(code), "expected a #{code} refusal, got #{response}"
14
14
  end
@@ -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
- def initialize(base_url:, token:, path: '/kiosk/events', timeout: 10)
39
- uri = URI(base_url)
40
- tls = uri.scheme == 'https'
41
- @url = "#{tls ? 'wss' : 'ws'}://#{uri.host}:#{uri.port}#{path}"
42
- @io = open_socket(uri.hostname, uri.port, tls)
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(token)
103
+ def start_driver(headers)
102
104
  driver = WebSocket::Driver.client(self)
103
- driver.set_header('Authorization', "Bearer #{token}")
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, then queries, runs and pays as the
14
- # principal it registered, paying every proof-of-work toll it is asked for.
14
+ # An assistant over HTTP: 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 registered, and the key that signs its mandates.
23
- Principal = Data.define(:agent_id, :user_id, :token, :rsa_key)
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) 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
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
- 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
61
+ # A new key, registered, holding its first token.
62
+ def register!(pow: :solve) = signed_in(register_client!(pow:))
42
63
 
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)
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
- # 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)))
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 }, wire.bearer(principal.token))
87
+ wire.post('/kiosk/agents/kyc', { kyc_jws: attestation_jws }, credential(principal))
55
88
  end
56
89
 
57
- # `pay_tolls: false` answers a proof-of-work toll instead of paying it.
90
+ # `pay_tolls: false` answers a proof-of-work toll instead of solving it.
58
91
  def query(principal, name:, headers: {}, pay_tolls: true, **params)
59
- (pay_tolls ? wire : unpaid).get("/kiosk/#{name}", params, wire.bearer(principal.token).merge(headers))
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, wire.bearer(principal.token).merge(headers))
96
+ wire.post("/kiosk/#{name}", args, credential(principal).merge(headers))
64
97
  end
65
98
 
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))
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, 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))
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, wire.bearer(principal.token))
113
+ wire.post('/kiosk/pay', body, credential(principal))
90
114
  end
91
115
 
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 }
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
- 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
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 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')
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 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]
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
- def possession(key) = { public_key: key.public_key.to_pem, signed: proof_of_possession(key) }
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 protocol 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