Skip to content

Commit

Permalink
Test for transaction ID semantics
Browse files Browse the repository at this point in the history
  • Loading branch information
hughns committed Mar 4, 2023
1 parent 6e900e0 commit 16054c8
Show file tree
Hide file tree
Showing 2 changed files with 213 additions and 2 deletions.
37 changes: 35 additions & 2 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,14 @@ func (c *CSAPI) SetPushRule(t *testing.T, scope string, kind string, ruleID stri
// SendEventUnsynced sends `e` into the room.
// Returns the event ID of the sent event.
func (c *CSAPI) SendEventUnsynced(t *testing.T, roomID string, e b.Event) string {
t.Helper()
txnID := int(atomic.AddInt64(&c.txnID, 1))
return c.SendEventUnsyncedWithTxnID(t, roomID, e, txnID)
}

// SendEventUnsynced sends `e` into the room.
// Returns the event ID of the sent event.
func (c *CSAPI) SendEventUnsyncedWithTxnID(t *testing.T, roomID string, e b.Event, txnID int) string {
t.Helper()
paths := []string{"_matrix", "client", "v3", "rooms", roomID, "send", e.Type, strconv.Itoa(txnID)}
if e.StateKey != nil {
paths = []string{"_matrix", "client", "v3", "rooms", roomID, "state", e.Type, *e.StateKey}
Expand Down Expand Up @@ -438,7 +444,34 @@ func (c *CSAPI) LoginUser(t *testing.T, localpart, password string) (userID, acc
return userID, accessToken, deviceID
}

//RegisterUser will register the user with given parameters and
// LoginUserWithDeviceID will log in to a homeserver on an existing device
func (c *CSAPI) LoginUserWithDeviceID(t *testing.T, localpart, password, deviceID string) (userID, accessToken string) {
t.Helper()
reqBody := map[string]interface{}{
"identifier": map[string]interface{}{
"type": "m.id.user",
"user": localpart,
},
"device_id": deviceID,
"password": password,
"type": "m.login.password",
}
res := c.MustDoFunc(t, "POST", []string{"_matrix", "client", "v3", "login"}, WithJSONBody(t, reqBody))

body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatalf("unable to read response body: %v", err)
}

userID = gjson.GetBytes(body, "user_id").Str
accessToken = gjson.GetBytes(body, "access_token").Str
if gjson.GetBytes(body, "device_id").Str != deviceID {
t.Fatalf("device_id returned by login does not match the one requested")
}
return userID, accessToken
}

// RegisterUser will register the user with given parameters and
// return user ID & access token, and fail the test on network error
func (c *CSAPI) RegisterUser(t *testing.T, localpart, password string) (userID, accessToken, deviceID string) {
t.Helper()
Expand Down
178 changes: 178 additions & 0 deletions tests/csapi/txnid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package csapi_tests

import (
"testing"

"github.com/matrix-org/complement/internal/b"
"github.com/matrix-org/complement/internal/client"
"github.com/matrix-org/complement/runtime"
"github.com/tidwall/gjson"
)

func mustHaveTransactionID(t *testing.T, roomID, eventID string) client.SyncCheckOpt {
return client.SyncTimelineHas(roomID, func(r gjson.Result) bool {
if r.Get("event_id").Str == eventID {
if !r.Get("unsigned.transaction_id").Exists() {
t.Fatalf("Event %s in room %s should have a 'transaction_id', but it did not", eventID, roomID)
}

return true
}

return false
})
}

func mustNotHaveTransactionID(t *testing.T, roomID, eventID string) client.SyncCheckOpt {
return client.SyncTimelineHas(roomID, func(r gjson.Result) bool {
if r.Get("event_id").Str == eventID {
res := r.Get("unsigned.transaction_id")
if res.Exists() {
t.Fatalf("Event %s in room %s should NOT have a 'transaction_id', but it did (%s)", eventID, roomID, res.Str)
}

return true
}

return false
})
}

// TestTxnScopeOnLocalEcho tests that transaction IDs are scoped to the access token, not the device
// on the sync response
func TestTxnScopeOnLocalEcho(t *testing.T) {
// Conduit scope transaction IDs to the device ID, not the access token.
runtime.SkipIf(t, runtime.Conduit)

deployment := Deploy(t, b.BlueprintCleanHS)
defer deployment.Destroy(t)

deployment.RegisterUser(t, "hs1", "alice", "password", false)

// Create a first client, which allocates a device ID.
c1 := deployment.Client(t, "hs1", "")
c1.UserID, c1.AccessToken, c1.DeviceID = c1.LoginUser(t, "alice", "password")

// Create a room where we can send events.
roomID := c1.CreateRoom(t, map[string]interface{}{})

// Let's send an event, and wait for it to appear in the timeline.
eventID := c1.SendEventUnsynced(t, roomID, b.Event{
Type: "m.room.message",
Content: map[string]interface{}{
"msgtype": "m.text",
"body": "first",
},
})

// When syncing, we should find the event and it should have a transaction ID on the first client.
c1.MustSyncUntil(t, client.SyncReq{}, mustHaveTransactionID(t, roomID, eventID))

// Create a second client, inheriting the first device ID.
c2 := deployment.Client(t, "hs1", "")
c2.UserID, c2.AccessToken = c2.LoginUserWithDeviceID(t, "alice", "password", c1.DeviceID)
c2.DeviceID = c1.DeviceID

// When syncing, we should find the event and it should *not* have a transaction ID on the second client.
c2.MustSyncUntil(t, client.SyncReq{}, mustNotHaveTransactionID(t, roomID, eventID))
}

// TestTxnIdempotencyNotScopedToDevicet tests that transaction IDs are not scoped to a device ID which is an
// anti-pattern
func TestTxnIdempotencyNotScopedToDevice(t *testing.T) {
// Conduit scope transaction IDs to the device ID, not the access token.
runtime.SkipIf(t, runtime.Conduit)

deployment := Deploy(t, b.BlueprintCleanHS)
defer deployment.Destroy(t)

deployment.RegisterUser(t, "hs1", "alice", "password", false)

// Create a first client, which allocates a device ID.
c1 := deployment.Client(t, "hs1", "")
c1.UserID, c1.AccessToken, c1.DeviceID = c1.LoginUser(t, "alice", "password")

// Create a room where we can send events.
roomID := c1.CreateRoom(t, map[string]interface{}{})

txnId := 1
event := b.Event{
Type: "m.room.message",
Content: map[string]interface{}{
"msgtype": "m.text",
"body": "foo",
},
}
// send an event with set txnId
eventID1 := c1.SendEventUnsyncedWithTxnID(t, roomID, event, txnId)

// Create a second client, inheriting the first device ID.
c2 := deployment.Client(t, "hs1", "")
c2.UserID, c2.AccessToken = c2.LoginUserWithDeviceID(t, "alice", "password", c1.DeviceID)
c2.DeviceID = c1.DeviceID

// send another event with the same txnId
eventID2 := c2.SendEventUnsyncedWithTxnID(t, roomID, event, txnId)

// the two events should have different event IDs as they came from different clients
if eventID1 == eventID2 {
t.Fatalf("Expected event IDs to be different from two clients sharing the same device ID")
}
}

// TestTxnIdempotency tests that PUT requests idempotency follows required semantics
func TestTxnIdempotency(t *testing.T) {
deployment := Deploy(t, b.BlueprintCleanHS)
defer deployment.Destroy(t)

deployment.RegisterUser(t, "hs1", "alice", "password", false)

// Create a first client, which allocates a device ID.
c1 := deployment.Client(t, "hs1", "")
c1.UserID, c1.AccessToken, c1.DeviceID = c1.LoginUser(t, "alice", "password")

// Create a room where we can send events.
roomID1 := c1.CreateRoom(t, map[string]interface{}{})
roomID2 := c1.CreateRoom(t, map[string]interface{}{})

// choose a transaction ID
txnId := 1
event1 := b.Event{
Type: "m.room.message",
Content: map[string]interface{}{
"msgtype": "m.text",
"body": "first",
},
}
event2 := b.Event{
Type: "m.room.message",
Content: map[string]interface{}{
"msgtype": "m.text",
"body": "second",
},
}

// we send the event and get an event ID back
eventID1 := c1.SendEventUnsyncedWithTxnID(t, roomID1, event1, txnId)

// we send the identical event again and should get back the same event ID
eventID2 := c1.SendEventUnsyncedWithTxnID(t, roomID1, event1, txnId)

if eventID1 != eventID2 {
t.Fatalf("Expected event IDs to be the same, but they were not")
}

// even if we change the content we should still get back the same event ID as transaction ID is the same
eventID3 := c1.SendEventUnsyncedWithTxnID(t, roomID1, event2, txnId)

if eventID1 != eventID3 {
t.Fatalf("Expected event IDs to be the same even with different content, but they were not")
}

// if we change the room ID we should be able to use the same transaction ID
eventID4 := c1.SendEventUnsyncedWithTxnID(t, roomID2, event1, txnId)

if eventID4 == eventID3 {
t.Fatalf("Expected event IDs to be the different, but they were not")
}
}

0 comments on commit 16054c8

Please sign in to comment.