async-matrix 3.0.0-aarch64-linux → 3.0.1-aarch64-linux

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: 7bef87f1b22f1fd59477479583775a4a235bd7985ae1c8567d3fe303572ab73f
4
- data.tar.gz: ce844a66c8973aedc7b417afd1f811c77dc436ae1539cbc05dee932f09cdc35b
3
+ metadata.gz: 2b5019a1e531cc2e4130955f983b0ab97858020be746cb05ed7c2fd0fdfa18d4
4
+ data.tar.gz: b9a60430770fc16598f4e7d8b9b02548f74b34804b3b077b61e534c1202df0f0
5
5
  SHA512:
6
- metadata.gz: 277f2e43ef49200ec76053fc316a994bf9881f5879e03d2bf00e0d0ae6c902608f74651f9e4b564336b05b229cff7619fe2814ec05be9227536cc508aea7070e
7
- data.tar.gz: d1c41dcb08c2fe00456dfe5f0162389fb27b419ef242b16efd9a3a451b474be1f94b17ff6d6fcf10b08caad469d3c08d0d808caebe7c876ebb12a6d8053c750d
6
+ metadata.gz: 1a59e748998b8f5a4629204ebec300ad78867953356517addcd22595762abff097d047c4265066bb8967a5604d2b3902f2da5a6a1164ee90b2218500fb6a4397
7
+ data.tar.gz: 309cc2fcd6ea442563e744d385fd0f1f4ce487bfc91c54e0e254ac643e2874eae46f85f1f3dcf540fdeb8257c4ec381ed7fccd3cfacf773a9acab59f532f5982
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ require_relative "client"
7
+
8
+ module Async
9
+ module Matrix
10
+ # A Client that acts AS one of the appservice's users, on one of that user's
11
+ # devices.
12
+ #
13
+ # client = AppServiceClient.new(config, user_id: "@ada:example.org", device_id: "ABCDEFGHIJ")
14
+ # client.upload_keys(device_keys: ...) # uploaded for Ada's device, not the bot's
15
+ #
16
+ # This is double puppeting, and it is the officially intended mechanism
17
+ # rather than a trick. Identity assertion -- `?user_id=` -- has been in the
18
+ # application service spec for years; [MSC4326] added `?device_id=` beside
19
+ # it and is MERGED, so the plain parameter names below are stable spec, not
20
+ # the unstable `org.matrix.msc3202.device_id` an older implementation would
21
+ # have sent.
22
+ #
23
+ # WHY NOT A TOKEN. Bridges used to get a per-user access token by calling
24
+ # /login with `m.login.application_service` ([MSC2778]), and that route is
25
+ # gone on a homeserver fronted by OAuth2 -- it answers
26
+ # M_APPSERVICE_LOGIN_UNSUPPORTED. [MSC4190], which is also merged, replaced
27
+ # it: an appservice creates devices directly (`PUT /devices/{deviceId}`) and
28
+ # then acts as them with these two parameters. No token is ever issued, so
29
+ # none can expire or need refreshing.
30
+ #
31
+ # IT STILL WORKS BEHIND MAS. Synapse checks an as_token against its own
32
+ # appservice registry before it introspects anything at the authentication
33
+ # service, so masquerading is unaffected by next-generation auth.
34
+ #
35
+ # THE USER MUST BE IN THE REGISTRATION'S NAMESPACE. The homeserver refuses
36
+ # otherwise -- which is why a double-puppet registration claims a wide user
37
+ # namespace non-exclusively rather than naming individuals.
38
+ #
39
+ # [MSC4326]: https://github.com/matrix-org/matrix-spec-proposals/pull/4326
40
+ # [MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190
41
+ # [MSC2778]: https://github.com/matrix-org/matrix-spec-proposals/pull/2778
42
+ class AppServiceClient < Client
43
+ # @parameter user_id [String] the user to act as. Must match the
44
+ # registration's user namespace.
45
+ # @parameter device_id [String] the device to act as.
46
+ #
47
+ # WITHOUT IT THE REQUEST HAS NO DEVICE, and an appservice request with
48
+ # no device cannot upload one-time keys, claim keys or send to-device
49
+ # messages -- every call encryption is made of. It is optional only
50
+ # because the device has to be CREATED before it can be acted as, and
51
+ # that one call is made without it.
52
+ def initialize(config, user_id:, device_id: nil, **options)
53
+ super(config, **options)
54
+
55
+ @user_id = user_id
56
+ @device_id = device_id
57
+ end
58
+
59
+ attr_reader :user_id, :device_id
60
+
61
+ # NAMED ON EVERY REQUEST, not only the ones that obviously need it. An
62
+ # appservice request that omits them is not an error: it silently acts as
63
+ # the registration's sender_localpart, with no device, which is far worse
64
+ # than a failure because it succeeds.
65
+ def default_query
66
+ {user_id: @user_id}.tap do |query|
67
+ if @device_id
68
+ query[:device_id] = @device_id
69
+ end
70
+ end
71
+ end
72
+
73
+ # A sibling client for another user or device, sharing this one's config
74
+ # and retry policy.
75
+ #
76
+ # One appservice commonly acts for many users, and the alternative --
77
+ # mutating user_id on a single client -- races itself the moment two
78
+ # fibers use it, which is exactly what an async bridge does.
79
+ def as(user_id:, device_id: nil)
80
+ self.class.new(@config, user_id: user_id, device_id: device_id)
81
+ end
82
+
83
+ # The same user, now on a device: what you call once the device exists.
84
+ def with_device(device_id)
85
+ as(user_id: @user_id, device_id: device_id)
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ __END__
92
+ describe "Async::Matrix::AppServiceClient" do
93
+ def config
94
+ Async::Matrix::Config.new({
95
+ "homeserver" => {"address" => "http://synapse:8008", "domain" => "example.org"},
96
+ "appservice" => {"as_token" => "as", "hs_token" => "hs", "bot" => {"username" => "bot"}},
97
+ })
98
+ end
99
+
100
+ def recording_client(user_id: "@ada:example.org", device_id: "ABCDEFGHIJ")
101
+ Async::Matrix::Api.reset!
102
+
103
+ client = Async::Matrix::AppServiceClient.new(config, user_id: user_id, device_id: device_id)
104
+ calls = []
105
+ client.define_singleton_method(:calls) { calls }
106
+ client.define_singleton_method(:internet) do
107
+ internet = Object.new
108
+ internet.define_singleton_method(:call) do |method, url, _headers, _body|
109
+ calls << [method, url]
110
+
111
+ # Shaped like what Client#read_limited consumes: a body that yields
112
+ # chunks and reports its own length.
113
+ body = Object.new
114
+ body.define_singleton_method(:length) { 2 }
115
+ body.define_singleton_method(:each) { |&block| block.call("{}") }
116
+ body.define_singleton_method(:close) { nil }
117
+
118
+ response = Object.new
119
+ response.define_singleton_method(:status) { 200 }
120
+ response.define_singleton_method(:body) { body }
121
+ response.define_singleton_method(:close) { nil }
122
+ response
123
+ end
124
+ internet
125
+ end
126
+ client
127
+ end
128
+
129
+ def last_url(client) = client.calls.last[1]
130
+
131
+ it "asserts the user and device on every request" do
132
+ client = recording_client
133
+ client.whoami
134
+
135
+ last_url(client).should ==
136
+ "http://synapse:8008/_matrix/client/v3/account/whoami" \
137
+ "?user_id=%40ada%3Aexample.org&device_id=ABCDEFGHIJ"
138
+ end
139
+
140
+ # Not only on the calls that obviously need it: an appservice request that
141
+ # omits them silently acts as the sender_localpart, with no device.
142
+ it "asserts them through the api chain too" do
143
+ client = recording_client
144
+ client.joined_members(room_id: "!ops:example.org")
145
+
146
+ last_url(client).should.be.include? "user_id=%40ada%3Aexample.org"
147
+ last_url(client).should.be.include? "device_id=ABCDEFGHIJ"
148
+ end
149
+
150
+ # The chain appends its own query first, so this is the case that catches a
151
+ # naive "?" concatenation.
152
+ it "appends to a path that already carries a query" do
153
+ client = recording_client
154
+ client.messages(room_id: "!ops:example.org", limit: 10)
155
+
156
+ last_url(client).should.be.include? "?dir=b&limit=10&"
157
+ last_url(client).should.be.include? "user_id=%40ada%3Aexample.org"
158
+ last_url(client).count("?").should == 1
159
+ end
160
+
161
+ # The device has to be created before it can be acted as, and that one call
162
+ # is made without a device.
163
+ it "omits the device when it has none" do
164
+ client = recording_client(device_id: nil)
165
+ client.whoami
166
+
167
+ last_url(client).should.be.include? "user_id=%40ada%3Aexample.org"
168
+ last_url(client).should.not.be.include? "device_id"
169
+ end
170
+
171
+ # Overwriting it would mean acting as somebody other than the caller asked
172
+ # for, which is worse than a failure.
173
+ it "lets a caller's own parameter win" do
174
+ client = recording_client
175
+ client.get("/_matrix/client/v3/account/whoami?user_id=%40bob%3Aexample.org")
176
+
177
+ last_url(client).should.be.include? "user_id=%40bob%3Aexample.org"
178
+ last_url(client).scan("user_id=").length.should == 1
179
+ end
180
+
181
+ it "encodes the punctuation in a user id" do
182
+ client = recording_client(user_id: "@ada:example.org")
183
+ client.whoami
184
+
185
+ last_url(client).should.be.include? "%40ada%3Aexample.org"
186
+ end
187
+
188
+ # One appservice acts for many users; mutating a shared client would race
189
+ # itself the moment two fibers used it.
190
+ it "makes a sibling client for another user" do
191
+ client = recording_client
192
+ other = client.as(user_id: "@bob:example.org", device_id: "OTHERDEV")
193
+
194
+ other.user_id.should == "@bob:example.org"
195
+ other.device_id.should == "OTHERDEV"
196
+ client.user_id.should == "@ada:example.org"
197
+ other.config.should.be == client.config
198
+ end
199
+
200
+ it "makes a client for the same user on a device" do
201
+ client = recording_client(device_id: nil)
202
+ with_device = client.with_device("NEWDEVICE")
203
+
204
+ with_device.user_id.should == "@ada:example.org"
205
+ with_device.device_id.should == "NEWDEVICE"
206
+ end
207
+
208
+ # An ordinary client asserts nothing: a user's own token already says who
209
+ # the request is for.
210
+ it "is the only client that asserts anything" do
211
+ Async::Matrix::Client.new(config).default_query.should == {}
212
+ end
213
+ end
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ require "securerandom"
7
+
8
+ module Async
9
+ module Matrix
10
+ class Client
11
+ # The endpoints that make a device exist, be reachable, and be able to
12
+ # share keys. Mixed into Client.
13
+ #
14
+ # ORDER MATTERS, and it is the one thing a reader needs from this file:
15
+ #
16
+ # register_user the account exists (appservices only)
17
+ # create_device the device exists, without /login (MSC4190)
18
+ # upload_keys other devices can now find and reach us
19
+ # query_keys we learn who else is in the room, and their keys
20
+ # claim_keys we take a key to open a session with one of them
21
+ # send_to_device we hand them a room key through that session
22
+ #
23
+ # Until `upload_keys` has run the device is invisible: nobody can claim a
24
+ # one-time key, so nobody can open an Olm session, so nobody can send us a
25
+ # room key, and every message in an encrypted room stays ciphertext. It is
26
+ # a precondition, not an optimisation.
27
+ #
28
+ # Every method routes through #api, so the path is validated against the
29
+ # vendored OpenAPI tree before a request is made -- a typo is an
30
+ # InvalidEndpointError here rather than a 404 from the homeserver.
31
+ module Encryption
32
+ # ── Publishing our own keys ───────────────────────────────────────────
33
+
34
+ # POST /keys/upload. Any combination of the three may be sent; a nil is
35
+ # omitted rather than sent as null.
36
+ #
37
+ # @parameter device_keys [Hash] from Protocol::Matrix::Keys.device_keys
38
+ # @parameter one_time_keys [Hash] from Keys.one_time_keys
39
+ # @parameter fallback_keys [Hash] from Keys.fallback_keys
40
+ # @returns [Hash] with `one_time_key_counts`, which is what tells us how
41
+ # many the server now holds.
42
+ def upload_keys(device_keys: nil, one_time_keys: nil, fallback_keys: nil)
43
+ body = {}
44
+
45
+ if device_keys
46
+ body[:device_keys] = device_keys
47
+ end
48
+
49
+ unless one_time_keys.nil? || one_time_keys.empty?
50
+ body[:one_time_keys] = one_time_keys
51
+ end
52
+
53
+ unless fallback_keys.nil? || fallback_keys.empty?
54
+ body[:fallback_keys] = fallback_keys
55
+ end
56
+
57
+ api.keys.upload.post(body)
58
+ end
59
+
60
+ # POST /keys/device_signing/upload -- the cross-signing keys.
61
+ #
62
+ # MSC4190 removed the user-interactive auth requirement here for
63
+ # appservices, which is what makes this reachable without a password.
64
+ # It matters because MSC4153 lets senders refuse to share room keys with
65
+ # a device that is not cross-signed.
66
+ def upload_cross_signing_keys(master_key: nil, self_signing_key: nil, user_signing_key: nil)
67
+ body = {}
68
+
69
+ if master_key
70
+ body[:master_key] = master_key
71
+ end
72
+
73
+ if self_signing_key
74
+ body[:self_signing_key] = self_signing_key
75
+ end
76
+
77
+ if user_signing_key
78
+ body[:user_signing_key] = user_signing_key
79
+ end
80
+
81
+ api.keys.device_signing.upload.post(body)
82
+ end
83
+
84
+ # POST /keys/signatures/upload -- our signatures over other keys.
85
+ def upload_signatures(signatures)
86
+ api.keys.signatures.upload.post(signatures)
87
+ end
88
+
89
+ # ── Learning about other devices ──────────────────────────────────────
90
+
91
+ # POST /keys/query. +user_ids+ is who to ask about; an empty device list
92
+ # per user means "all of their devices".
93
+ #
94
+ # @parameter token [String] the `device_lists` sync token, so the server
95
+ # can tell us whether our view is already current.
96
+ def query_keys(user_ids:, token: nil)
97
+ body = {device_keys: user_ids.to_h { |user_id| [user_id, []] }}
98
+
99
+ if token
100
+ body[:token] = token
101
+ end
102
+
103
+ api.keys.query.post(body)
104
+ end
105
+
106
+ # POST /keys/claim -- take one one-time key per device, to open a
107
+ # session.
108
+ #
109
+ # ONLY FOR DEVICES WE HAVE NO SESSION WITH. Each claim consumes a key
110
+ # from a finite pool, so claiming for a device we can already reach
111
+ # burns one for nothing.
112
+ #
113
+ # @parameter devices [Hash] { "@user:server" => ["DEVICEID", ...] }
114
+ def claim_keys(devices:, algorithm: Protocol::Matrix::Keys::SIGNED_CURVE25519, timeout: nil)
115
+ body = {
116
+ one_time_keys: devices.transform_values { |ids|
117
+ ids.to_h { |device_id| [device_id, algorithm] }
118
+ },
119
+ }
120
+
121
+ if timeout
122
+ body[:timeout] = timeout
123
+ end
124
+
125
+ api.keys.claim.post(body)
126
+ end
127
+
128
+ # ── Talking to devices directly ───────────────────────────────────────
129
+
130
+ # PUT /sendToDevice/{eventType}/{txnId}
131
+ #
132
+ # @parameter messages [Hash] { "@user:server" => { "DEVICEID" => content } }
133
+ #
134
+ # This is how a room key travels. The transaction id makes it
135
+ # idempotent, so a retry after a timeout cannot deliver twice.
136
+ def send_to_device(event_type:, messages:, txn_id: nil)
137
+ api.sendToDevice(event_type, txn_id || SecureRandom.uuid).put({messages: messages})
138
+ end
139
+
140
+ # ── Device lifecycle ──────────────────────────────────────────────────
141
+
142
+ # PUT /devices/{deviceId} -- MSC4190.
143
+ #
144
+ # THE REASON THIS EXISTS: appservices used to create devices by calling
145
+ # /login with `m.login.application_service`, and that route is gone on a
146
+ # homeserver fronted by OAuth2 (MAS). This endpoint creates the device
147
+ # with no login at all, answering 201 for a new one and 200 for one that
148
+ # already existed.
149
+ #
150
+ # The device id is OURS to choose and must never change afterwards: it
151
+ # is the identity every room key we hold is bound to.
152
+ def create_device(device_id:, display_name: nil)
153
+ body = {}
154
+
155
+ if display_name
156
+ body[:display_name] = display_name
157
+ end
158
+
159
+ api.devices(device_id).put(body)
160
+ end
161
+
162
+ # DELETE /devices/{deviceId}. MSC4190 removed the UIA requirement for
163
+ # appservices, which is what makes this callable unattended.
164
+ def delete_device(device_id:)
165
+ api.devices(device_id).delete
166
+ end
167
+
168
+ def devices = api.devices.get
169
+
170
+ def device(device_id:) = api.devices(device_id).get
171
+
172
+ # POST /register for an appservice-owned user.
173
+ #
174
+ # `inhibit_login` IS MANDATORY, not tidiness: honouring a login would
175
+ # mean issuing an access token, and under OAuth2 the homeserver no
176
+ # longer owns that -- so without it the call fails with
177
+ # M_APPSERVICE_LOGIN_UNSUPPORTED.
178
+ def register_user(username:)
179
+ api.register.post(
180
+ {
181
+ type: "m.login.application_service",
182
+ username: username,
183
+ inhibit_login: true,
184
+ },
185
+ )
186
+ end
187
+
188
+ # ── Key backup ────────────────────────────────────────────────────────
189
+
190
+ # GET /room_keys/version[/{version}] -- the backup's algorithm and
191
+ # auth_data, which carries the public key it was encrypted to.
192
+ def key_backup_version(version: nil)
193
+ if version
194
+ api.room_keys.version(version).get
195
+ else
196
+ api.room_keys.version.get
197
+ end
198
+ end
199
+
200
+ # GET /room_keys/keys -- every backed-up session, as
201
+ # rooms -> sessions -> session_data blobs for
202
+ # Protocol::Matrix::KeyBackup to decrypt.
203
+ def room_keys(version:)
204
+ # Plain keys, not the "?"-prefixed form: that convention exists to
205
+ # separate query params from a BODY on POST/PUT, and a GET has no
206
+ # body -- every kwarg is already a query parameter.
207
+ api.room_keys.keys.get(version: version)
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+
214
+ __END__
215
+ describe "Async::Matrix::Client::Encryption" do
216
+ # A real Client with its transport stubbed, so these specs exercise the
217
+ # actual path construction AND the OpenAPI path-tree validation -- a wrong
218
+ # path fails here rather than as a 404 from a homeserver.
219
+ def recording_client(response = {})
220
+ # The REAL path tree. Api memoises it process-wide, and the Api specs
221
+ # inject a small fixture tree without restoring it (Api.reset! exists for
222
+ # that and goes uncalled), so without this these specs pass or fail
223
+ # according to the order scampi happens to load files in.
224
+ Async::Matrix::Api.reset!
225
+
226
+ config = Async::Matrix::Config.new({
227
+ "homeserver" => {"address" => "http://synapse:8008", "domain" => "example.org"},
228
+ "appservice" => {"as_token" => "as", "hs_token" => "hs", "bot" => {"username" => "bot"}},
229
+ })
230
+ client = Async::Matrix::Client.new(config)
231
+ calls = []
232
+ client.define_singleton_method(:calls) { calls }
233
+ client.define_singleton_method(:request) do |method, path, body = nil, **_options|
234
+ calls << [method, path, body]
235
+ response
236
+ end
237
+ client
238
+ end
239
+
240
+ def last(client) = client.calls.last
241
+
242
+ # ── Publishing ────────────────────────────────────────────────────────────
243
+
244
+ it "uploads device keys" do
245
+ client = recording_client
246
+ client.upload_keys(device_keys: {"user_id" => "@bot:example.org"})
247
+
248
+ last(client)[0].should == "POST"
249
+ last(client)[1].should == "/_matrix/client/v3/keys/upload"
250
+ last(client)[2].should == {device_keys: {"user_id" => "@bot:example.org"}}
251
+ end
252
+
253
+ it "uploads all three kinds of key together" do
254
+ client = recording_client
255
+ client.upload_keys(
256
+ device_keys: {"a" => 1},
257
+ one_time_keys: {"signed_curve25519:k" => {"key" => "k"}},
258
+ fallback_keys: {"signed_curve25519:f" => {"key" => "f", "fallback" => true}},
259
+ )
260
+
261
+ last(client)[2].keys.sort.should == [:device_keys, :fallback_keys, :one_time_keys]
262
+ end
263
+
264
+ # A nil is omitted rather than sent as null -- "May be absent if no new
265
+ # one-time keys are required".
266
+ it "omits what it was not given" do
267
+ client = recording_client
268
+ client.upload_keys(one_time_keys: {})
269
+
270
+ last(client)[2].should == {}
271
+ end
272
+
273
+ it "uploads cross-signing keys" do
274
+ client = recording_client
275
+ client.upload_cross_signing_keys(master_key: {"keys" => {}})
276
+
277
+ last(client)[1].should == "/_matrix/client/v3/keys/device_signing/upload"
278
+ last(client)[2].should == {master_key: {"keys" => {}}}
279
+ end
280
+
281
+ it "uploads signatures" do
282
+ client = recording_client
283
+ client.upload_signatures({"@bot:example.org" => {"ed25519:DEV" => {}}})
284
+
285
+ last(client)[1].should == "/_matrix/client/v3/keys/signatures/upload"
286
+ end
287
+
288
+ # ── Querying ──────────────────────────────────────────────────────────────
289
+
290
+ # An empty device list per user means "all of their devices".
291
+ it "queries keys for a set of users" do
292
+ client = recording_client
293
+ client.query_keys(user_ids: ["@ada:example.org", "@bob:example.org"])
294
+
295
+ last(client)[1].should == "/_matrix/client/v3/keys/query"
296
+ last(client)[2].should == {
297
+ device_keys: {"@ada:example.org" => [], "@bob:example.org" => []},
298
+ }
299
+ end
300
+
301
+ it "passes the device_lists token when it has one" do
302
+ client = recording_client
303
+ client.query_keys(user_ids: ["@ada:example.org"], token: "s72")
304
+
305
+ last(client)[2][:token].should == "s72"
306
+ end
307
+
308
+ it "claims one key per device, naming the algorithm" do
309
+ client = recording_client
310
+ client.claim_keys(devices: {"@ada:example.org" => ["DEV1", "DEV2"]})
311
+
312
+ last(client)[1].should == "/_matrix/client/v3/keys/claim"
313
+ last(client)[2].should == {
314
+ one_time_keys: {
315
+ "@ada:example.org" => {
316
+ "DEV1" => "signed_curve25519",
317
+ "DEV2" => "signed_curve25519",
318
+ },
319
+ },
320
+ }
321
+ end
322
+
323
+ # ── To-device ─────────────────────────────────────────────────────────────
324
+
325
+ it "sends to-device messages with a transaction id" do
326
+ client = recording_client
327
+ client.send_to_device(
328
+ event_type: "m.room.encrypted",
329
+ messages: {"@ada:example.org" => {"DEV1" => {"algorithm" => "m.olm.v1.curve25519-aes-sha2"}}},
330
+ )
331
+
332
+ last(client)[0].should == "PUT"
333
+ last(client)[1].should.be.start_with? "/_matrix/client/v3/sendToDevice/m.room.encrypted/"
334
+ last(client)[2].should == {
335
+ messages: {"@ada:example.org" => {"DEV1" => {"algorithm" => "m.olm.v1.curve25519-aes-sha2"}}},
336
+ }
337
+ end
338
+
339
+ # Idempotence: a retry after a timeout must not deliver twice.
340
+ it "lets the caller supply the transaction id" do
341
+ client = recording_client
342
+ client.send_to_device(event_type: "m.room.encrypted", messages: {}, txn_id: "txn1")
343
+
344
+ last(client)[1].should == "/_matrix/client/v3/sendToDevice/m.room.encrypted/txn1"
345
+ end
346
+
347
+ it "generates a different transaction id each time" do
348
+ client = recording_client
349
+ client.send_to_device(event_type: "m.room.encrypted", messages: {})
350
+ client.send_to_device(event_type: "m.room.encrypted", messages: {})
351
+
352
+ client.calls[0][1].should.not == client.calls[1][1]
353
+ end
354
+
355
+ # ── Devices ───────────────────────────────────────────────────────────────
356
+
357
+ # MSC4190: creates the device with no /login, which is the only way under
358
+ # OAuth2.
359
+ it "creates a device" do
360
+ client = recording_client
361
+ client.create_device(device_id: "ABCDEFGHIJ", display_name: "controller")
362
+
363
+ last(client)[0].should == "PUT"
364
+ last(client)[1].should == "/_matrix/client/v3/devices/ABCDEFGHIJ"
365
+ last(client)[2].should == {display_name: "controller"}
366
+ end
367
+
368
+ it "deletes a device" do
369
+ client = recording_client
370
+ client.delete_device(device_id: "ABCDEFGHIJ")
371
+
372
+ last(client)[0].should == "DELETE"
373
+ last(client)[1].should == "/_matrix/client/v3/devices/ABCDEFGHIJ"
374
+ end
375
+
376
+ # inhibit_login is mandatory under OAuth2: honouring a login would mean
377
+ # issuing an access token the homeserver no longer owns.
378
+ it "registers an appservice user without logging it in" do
379
+ client = recording_client
380
+ client.register_user(username: "controller")
381
+
382
+ last(client)[1].should == "/_matrix/client/v3/register"
383
+ last(client)[2].should == {
384
+ type: "m.login.application_service",
385
+ username: "controller",
386
+ inhibit_login: true,
387
+ }
388
+ end
389
+
390
+ # ── Key backup ────────────────────────────────────────────────────────────
391
+
392
+ it "reads the current backup version" do
393
+ client = recording_client
394
+ client.key_backup_version
395
+
396
+ last(client)[0].should == "GET"
397
+ last(client)[1].should == "/_matrix/client/v3/room_keys/version"
398
+ end
399
+
400
+ it "reads a specific backup version" do
401
+ client = recording_client
402
+ client.key_backup_version(version: "3")
403
+
404
+ last(client)[1].should == "/_matrix/client/v3/room_keys/version/3"
405
+ end
406
+
407
+ it "fetches the backed-up keys for a version" do
408
+ client = recording_client
409
+ client.room_keys(version: "3")
410
+
411
+ last(client)[1].should == "/_matrix/client/v3/room_keys/keys?version=3"
412
+ end
413
+ end