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 +4 -4
- data/CHANGELOG.md +155 -0
- data/README.md +71 -26
- data/lib/kiosk/story_test.rb +1 -1
- data/lib/kiosk/test_helpers/assistant/events.rb +10 -8
- data/lib/kiosk/test_helpers/assistant.rb +92 -72
- data/lib/kiosk/test_helpers/conformance/checks/routes.rb +1 -1
- data/lib/kiosk/test_helpers/conformance/in_process_origin.rb +1 -1
- data/lib/kiosk/test_helpers/customer.rb +17 -22
- data/lib/kiosk/test_helpers/dpop.rb +59 -0
- data/lib/kiosk/test_helpers/errors.rb +2 -2
- data/lib/kiosk/test_helpers/in_process_wire.rb +31 -17
- data/lib/kiosk/test_helpers/kyc.rb +15 -15
- data/lib/kiosk/test_helpers/live_server.rb +9 -8
- data/lib/kiosk/test_helpers/mandate.rb +46 -0
- data/lib/kiosk/test_helpers/pow_solver.rb +19 -0
- data/lib/kiosk/test_helpers/skill_pin/rspec.rb +17 -0
- data/lib/kiosk/test_helpers/skill_pin.rb +66 -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 +34 -11
- metadata +26 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c1a0b3e7ba79f9c1cf3f0e0ed29b2d9c7ed3deb87eed7deae9e030637d177903
|
|
4
|
+
data.tar.gz: 9df61c38d3daefdc760b862cdc84e071e049ee0753172dde44877585e9949161
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
8
|
-
- `Kiosk::TestHelpers::Wire` — the same transport without
|
|
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"
|
|
23
|
+
gem "kiosk-rls-rspec" # RSpec
|
|
25
24
|
# or
|
|
26
|
-
gem "kiosk-rls-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
|
-
|
|
36
|
-
run_action :create_order, items: ["bread"]
|
|
37
|
-
end
|
|
41
|
+
kiosk_seed :orders, owner: alice, item: "bread"
|
|
38
42
|
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
expect
|
|
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
|
-
##
|
|
55
|
+
## Configuring an executor
|
|
51
56
|
|
|
52
|
-
The DSL is inert until you
|
|
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
|
|
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
|
|
75
|
+
## Driving the protocol as an assistant
|
|
71
76
|
|
|
72
|
-
`Kiosk::TestHelpers::Assistant` registers,
|
|
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
|
|
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(
|
|
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:)`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
data/lib/kiosk/story_test.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Kiosk
|
|
|
8
8
|
class StoryTest < ActiveSupport::TestCase
|
|
9
9
|
include TestHelpers::Story
|
|
10
10
|
|
|
11
|
-
# Asserts the
|
|
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
|
-
|
|
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
|
|
14
|
-
#
|
|
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
|
|
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
|
-
# `pay_tolls: false` answers a proof-of-work toll instead of
|
|
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,
|
|
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 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
|