Skip to content

Commit d776cd1

Browse files
committed
Test the scope of a transaction IDs
This adds two tests, which check the current spec behaviour of transaction IDs, which are that they are scoped to a series of access tokens, and not the device ID. The first test highlight this behaviour, by logging in with refresh token enabled, sending an event, using the refresh token and syncing with the new access token. On the sync, the transaction ID should be there, but currently in Synapse it is not. The second test highlight that the transaction ID is not scoped to the device ID, by logging in twice with the same device ID, sending an event with the first access token, and syncing with the second access token. In that case, the sync should not contain the transaction ID, but I think it's the case in HS implementations which use the device ID to scope the transaction IDs, like Conduit. Related: matrix-org/matrix-spec#1133, matrix-org/matrix-spec#1236, matrix-org/synapse#13064 and matrix-org/synapse#13083
1 parent 67644cd commit d776cd1

File tree

3 files changed

+221
-22
lines changed

3 files changed

+221
-22
lines changed

internal/client/client.go

Lines changed: 99 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -335,24 +335,27 @@ func (c *CSAPI) MustSync(t *testing.T, syncReq SyncReq) (gjson.Result, string) {
335335
// check functions return no error. Returns the final/latest since token.
336336
//
337337
// Initial /sync example: (no since token)
338-
// bob.InviteRoom(t, roomID, alice.UserID)
339-
// alice.JoinRoom(t, roomID, nil)
340-
// alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))
338+
//
339+
// bob.InviteRoom(t, roomID, alice.UserID)
340+
// alice.JoinRoom(t, roomID, nil)
341+
// alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))
341342
//
342343
// Incremental /sync example: (test controls since token)
343-
// since := alice.MustSyncUntil(t, client.SyncReq{TimeoutMillis: "0"}) // get a since token
344-
// bob.InviteRoom(t, roomID, alice.UserID)
345-
// since = alice.MustSyncUntil(t, client.SyncReq{Since: since}, client.SyncInvitedTo(alice.UserID, roomID))
346-
// alice.JoinRoom(t, roomID, nil)
347-
// alice.MustSyncUntil(t, client.SyncReq{Since: since}, client.SyncJoinedTo(alice.UserID, roomID))
344+
//
345+
// since := alice.MustSyncUntil(t, client.SyncReq{TimeoutMillis: "0"}) // get a since token
346+
// bob.InviteRoom(t, roomID, alice.UserID)
347+
// since = alice.MustSyncUntil(t, client.SyncReq{Since: since}, client.SyncInvitedTo(alice.UserID, roomID))
348+
// alice.JoinRoom(t, roomID, nil)
349+
// alice.MustSyncUntil(t, client.SyncReq{Since: since}, client.SyncJoinedTo(alice.UserID, roomID))
348350
//
349351
// Checking multiple parts of /sync:
350-
// alice.MustSyncUntil(
351-
// t, client.SyncReq{},
352-
// client.SyncJoinedTo(alice.UserID, roomID),
353-
// client.SyncJoinedTo(alice.UserID, roomID2),
354-
// client.SyncJoinedTo(alice.UserID, roomID3),
355-
// )
352+
//
353+
// alice.MustSyncUntil(
354+
// t, client.SyncReq{},
355+
// client.SyncJoinedTo(alice.UserID, roomID),
356+
// client.SyncJoinedTo(alice.UserID, roomID2),
357+
// client.SyncJoinedTo(alice.UserID, roomID3),
358+
// )
356359
//
357360
// Check functions are unordered and independent. Once a check function returns true it is removed
358361
// from the list of checks and won't be called again.
@@ -438,7 +441,81 @@ func (c *CSAPI) LoginUser(t *testing.T, localpart, password string) (userID, acc
438441
return userID, accessToken, deviceID
439442
}
440443

441-
//RegisterUser will register the user with given parameters and
444+
// LoginUserWithDeviceID will log in to a homeserver on an existing device
445+
func (c *CSAPI) LoginUserWithDeviceID(t *testing.T, localpart, password, deviceID string) (userID, accessToken string) {
446+
t.Helper()
447+
reqBody := map[string]interface{}{
448+
"identifier": map[string]interface{}{
449+
"type": "m.id.user",
450+
"user": localpart,
451+
},
452+
"device_id": deviceID,
453+
"password": password,
454+
"type": "m.login.password",
455+
}
456+
res := c.MustDoFunc(t, "POST", []string{"_matrix", "client", "v3", "login"}, WithJSONBody(t, reqBody))
457+
458+
body, err := ioutil.ReadAll(res.Body)
459+
if err != nil {
460+
t.Fatalf("unable to read response body: %v", err)
461+
}
462+
463+
userID = gjson.GetBytes(body, "user_id").Str
464+
accessToken = gjson.GetBytes(body, "access_token").Str
465+
if gjson.GetBytes(body, "device_id").Str != deviceID {
466+
t.Fatalf("device_id returned by login does not match the one requested")
467+
}
468+
return userID, accessToken
469+
}
470+
471+
// LoginUserWithRefreshToken will log in to a homeserver, with refresh token enabled,
472+
// and create a new device on an existing user.
473+
func (c *CSAPI) LoginUserWithRefreshToken(t *testing.T, localpart, password string) (userID, accessToken, refreshToken, deviceID string, expiresInMs int64) {
474+
t.Helper()
475+
reqBody := map[string]interface{}{
476+
"identifier": map[string]interface{}{
477+
"type": "m.id.user",
478+
"user": localpart,
479+
},
480+
"password": password,
481+
"type": "m.login.password",
482+
"refresh_token": true,
483+
}
484+
res := c.MustDoFunc(t, "POST", []string{"_matrix", "client", "v3", "login"}, WithJSONBody(t, reqBody))
485+
486+
body, err := ioutil.ReadAll(res.Body)
487+
if err != nil {
488+
t.Fatalf("unable to read response body: %v", err)
489+
}
490+
491+
userID = gjson.GetBytes(body, "user_id").Str
492+
accessToken = gjson.GetBytes(body, "access_token").Str
493+
deviceID = gjson.GetBytes(body, "device_id").Str
494+
refreshToken = gjson.GetBytes(body, "refresh_token").Str
495+
expiresInMs = gjson.GetBytes(body, "expires_in_ms").Int()
496+
return userID, accessToken, refreshToken, deviceID, expiresInMs
497+
}
498+
499+
// RefreshToken will consume a refresh token and return a new access token and refresh token.
500+
func (c *CSAPI) ConsumeRefreshToken(t *testing.T, refreshToken string) (newAccessToken, newRefreshToken string, expiresInMs int64) {
501+
t.Helper()
502+
reqBody := map[string]interface{}{
503+
"refresh_token": refreshToken,
504+
}
505+
res := c.MustDoFunc(t, "POST", []string{"_matrix", "client", "v3", "refresh"}, WithJSONBody(t, reqBody))
506+
507+
body, err := ioutil.ReadAll(res.Body)
508+
if err != nil {
509+
t.Fatalf("unable to read response body: %v", err)
510+
}
511+
512+
newAccessToken = gjson.GetBytes(body, "access_token").Str
513+
newRefreshToken = gjson.GetBytes(body, "refresh_token").Str
514+
expiresInMs = gjson.GetBytes(body, "expires_in_ms").Int()
515+
return newAccessToken, newRefreshToken, expiresInMs
516+
}
517+
518+
// RegisterUser will register the user with given parameters and
442519
// return user ID & access token, and fail the test on network error
443520
func (c *CSAPI) RegisterUser(t *testing.T, localpart, password string) (userID, accessToken, deviceID string) {
444521
t.Helper()
@@ -598,12 +675,13 @@ func (c *CSAPI) MustDoFunc(t *testing.T, method string, paths []string, opts ...
598675
//
599676
// Fails the test if an HTTP request could not be made or if there was a network error talking to the
600677
// server. To do assertions on the HTTP response, see the `must` package. For example:
601-
// must.MatchResponse(t, res, match.HTTPResponse{
602-
// StatusCode: 400,
603-
// JSON: []match.JSON{
604-
// match.JSONKeyEqual("errcode", "M_INVALID_USERNAME"),
605-
// },
606-
// })
678+
//
679+
// must.MatchResponse(t, res, match.HTTPResponse{
680+
// StatusCode: 400,
681+
// JSON: []match.JSON{
682+
// match.JSONKeyEqual("errcode", "M_INVALID_USERNAME"),
683+
// },
684+
// })
607685
func (c *CSAPI) DoFunc(t *testing.T, method string, paths []string, opts ...RequestOpt) *http.Response {
608686
t.Helper()
609687
for i := range paths {

internal/docker/deployment.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (d *Deployment) Client(t *testing.T, hsName, userID string) *client.CSAPI {
9191

9292
// NewUser creates a new user as a convenience method to RegisterUser.
9393
//
94-
//It registers the user with a deterministic password, and without admin privileges.
94+
// It registers the user with a deterministic password, and without admin privileges.
9595
func (d *Deployment) NewUser(t *testing.T, localpart, hs string) *client.CSAPI {
9696
return d.RegisterUser(t, hs, localpart, "complement_meets_min_pasword_req_"+localpart, false)
9797
}

tests/csapi/txnid_scope_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package csapi_tests
2+
3+
import (
4+
"testing"
5+
6+
"github.com/matrix-org/complement/internal/b"
7+
"github.com/matrix-org/complement/internal/client"
8+
"github.com/matrix-org/complement/runtime"
9+
"github.com/tidwall/gjson"
10+
)
11+
12+
func mustHaveTransactionID(t *testing.T, roomID, eventID string) client.SyncCheckOpt {
13+
return client.SyncTimelineHas(roomID, func(r gjson.Result) bool {
14+
if r.Get("event_id").Str == eventID {
15+
if !r.Get("unsigned.transaction_id").Exists() {
16+
t.Fatalf("Event %s in room %s should have a 'transaction_id', but it did not", eventID, roomID)
17+
}
18+
19+
return true
20+
}
21+
22+
return false
23+
})
24+
}
25+
26+
func mustNotHaveTransactionID(t *testing.T, roomID, eventID string) client.SyncCheckOpt {
27+
return client.SyncTimelineHas(roomID, func(r gjson.Result) bool {
28+
if r.Get("event_id").Str == eventID {
29+
res := r.Get("unsigned.transaction_id")
30+
if res.Exists() {
31+
t.Fatalf("Event %s in room %s should NOT have a 'transaction_id', but it did (%s)", eventID, roomID, res.Str)
32+
}
33+
34+
return true
35+
}
36+
37+
return false
38+
})
39+
}
40+
41+
// TestTxnAfterRefresh tests that when a client refreshes its access token,
42+
// it still gets back a transaction ID in the sync response.
43+
func TestTxnAfterRefresh(t *testing.T) {
44+
// Dendrite doesn't support refresh tokens yet.
45+
runtime.SkipIf(t, runtime.Dendrite)
46+
47+
deployment := Deploy(t, b.BlueprintCleanHS)
48+
defer deployment.Destroy(t)
49+
50+
deployment.RegisterUser(t, "hs1", "alice", "password", false)
51+
52+
c := deployment.Client(t, "hs1", "")
53+
54+
var refreshToken string
55+
c.UserID, c.AccessToken, refreshToken, c.DeviceID, _ = c.LoginUserWithRefreshToken(t, "alice", "password")
56+
57+
// Create a room where we can send events.
58+
roomID := c.CreateRoom(t, map[string]interface{}{})
59+
60+
// Let's send an event, and wait for it to appear in the sync.
61+
eventID := c.SendEventUnsynced(t, roomID, b.Event{
62+
Type: "m.room.message",
63+
Content: map[string]interface{}{
64+
"msgtype": "m.text",
65+
"body": "first",
66+
},
67+
})
68+
69+
// When syncing, we should find the event and it should have a transaction ID.
70+
token := c.MustSyncUntil(t, client.SyncReq{}, mustHaveTransactionID(t, roomID, eventID))
71+
72+
// Now do the same, but refresh the token before syncing.
73+
eventID = c.SendEventUnsynced(t, roomID, b.Event{
74+
Type: "m.room.message",
75+
Content: map[string]interface{}{
76+
"msgtype": "m.text",
77+
"body": "second",
78+
},
79+
})
80+
81+
// Use the refresh token to get a new access token.
82+
c.AccessToken, refreshToken, _ = c.ConsumeRefreshToken(t, refreshToken)
83+
84+
// When syncing, we should find the event and it should also have a transaction ID.
85+
c.MustSyncUntil(t, client.SyncReq{Since: token}, mustHaveTransactionID(t, roomID, eventID))
86+
}
87+
88+
// TestTxnScope tests that transaction IDs are scoped to the access token, not the device
89+
func TestTxnScope(t *testing.T) {
90+
deployment := Deploy(t, b.BlueprintCleanHS)
91+
defer deployment.Destroy(t)
92+
93+
deployment.RegisterUser(t, "hs1", "alice", "password", false)
94+
95+
// Create a first client, which allocates a device ID.
96+
c1 := deployment.Client(t, "hs1", "")
97+
c1.UserID, c1.AccessToken, c1.DeviceID = c1.LoginUser(t, "alice", "password")
98+
99+
// Create a room where we can send events.
100+
roomID := c1.CreateRoom(t, map[string]interface{}{})
101+
102+
// Let's send an event, and wait for it to appear in the timeline.
103+
eventID := c1.SendEventUnsynced(t, roomID, b.Event{
104+
Type: "m.room.message",
105+
Content: map[string]interface{}{
106+
"msgtype": "m.text",
107+
"body": "first",
108+
},
109+
})
110+
111+
// When syncing, we should find the event and it should have a transaction ID on the first client.
112+
c1.MustSyncUntil(t, client.SyncReq{}, mustHaveTransactionID(t, roomID, eventID))
113+
114+
// Create a second client, inheriting the first device ID.
115+
c2 := deployment.Client(t, "hs1", "")
116+
c2.UserID, c2.AccessToken = c2.LoginUserWithDeviceID(t, "alice", "password", c1.DeviceID)
117+
c2.DeviceID = c1.DeviceID
118+
119+
// When syncing, we should find the event and it should *not* have a transaction ID on the second client.
120+
c2.MustSyncUntil(t, client.SyncReq{}, mustNotHaveTransactionID(t, roomID, eventID))
121+
}

0 commit comments

Comments
 (0)