Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tests for transaction ID semantics #622

Merged
merged 7 commits into from
Apr 13, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,14 @@ func (c *CSAPI) SetPushRule(t *testing.T, scope string, kind string, ruleID stri
func (c *CSAPI) SendEventUnsynced(t *testing.T, roomID string, e b.Event) string {
t.Helper()
txnID := int(atomic.AddInt64(&c.txnID, 1))
hughns marked this conversation as resolved.
Show resolved Hide resolved
paths := []string{"_matrix", "client", "v3", "rooms", roomID, "send", e.Type, strconv.Itoa(txnID)}
return c.SendEventUnsyncedWithTxnID(t, roomID, e, strconv.Itoa(txnID))
}

// SendEventUnsynced sends `e` into the room.
hughns marked this conversation as resolved.
Show resolved Hide resolved
// Returns the event ID of the sent event.
func (c *CSAPI) SendEventUnsyncedWithTxnID(t *testing.T, roomID string, e b.Event, txnID string) string {
t.Helper()
paths := []string{"_matrix", "client", "v3", "rooms", roomID, "send", e.Type, txnID}
if e.StateKey != nil {
paths = []string{"_matrix", "client", "v3", "rooms", roomID, "state", e.Type, *e.StateKey}
}
Expand Down Expand Up @@ -414,8 +421,16 @@ func (c *CSAPI) MustSyncUntil(t *testing.T, syncReq SyncReq, checks ...SyncCheck
}
}

type LoginOpt func(map[string]interface{})

func WithDeviceID(deviceID string) LoginOpt {
return func(loginBody map[string]interface{}) {
loginBody["device_id"] = deviceID
}
}

// LoginUser will log in to a homeserver and create a new device on an existing user.
func (c *CSAPI) LoginUser(t *testing.T, localpart, password string) (userID, accessToken, deviceID string) {
func (c *CSAPI) LoginUser(t *testing.T, localpart, password string, opts ...LoginOpt) (userID, accessToken, deviceID string) {
t.Helper()
reqBody := map[string]interface{}{
"identifier": map[string]interface{}{
Expand All @@ -425,6 +440,11 @@ func (c *CSAPI) LoginUser(t *testing.T, localpart, password string) (userID, acc
"password": password,
"type": "m.login.password",
}

for _, opt := range opts {
opt(reqBody)
}

res := c.MustDoFunc(t, "POST", []string{"_matrix", "client", "v3", "login"}, WithJSONBody(t, reqBody))

body, err := ioutil.ReadAll(res.Body)
Expand All @@ -438,7 +458,7 @@ 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
// RegisterUser will register the user with given parameters and
// return user ID & access token, and fail the test on network error
hughns marked this conversation as resolved.
Show resolved Hide resolved
func (c *CSAPI) RegisterUser(t *testing.T, localpart, password string) (userID, accessToken, deviceID string) {
t.Helper()
Expand Down
185 changes: 180 additions & 5 deletions tests/csapi/txnid_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package csapi_tests

import (
"fmt"
"testing"

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

// TestTxnInEvent checks that the transaction ID is present when getting the event from the /rooms/{roomID}/event/{eventID} endpoint.
Expand All @@ -22,20 +25,192 @@ func TestTxnInEvent(t *testing.T) {
// Create a room where we can send events.
roomID := c.CreateRoom(t, map[string]interface{}{})

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

// The transaction ID should be present on the GET /rooms/{roomID}/event/{eventID} response.
res := c.MustDoFunc(t, "GET", []string{"_matrix", "client", "v3", "rooms", roomID, "event", eventID})
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
body := client.ParseJSON(t, res)
result := gjson.ParseBytes(body)
if !result.Get("unsigned.transaction_id").Exists() {
t.Fatalf("Event did not have a 'transaction_id' on the GET /rooms/%s/event/%s response", roomID, eventID)
unsignedTxnId := result.Get("unsigned.transaction_id")
if !unsignedTxnId.Exists() {
t.Fatalf("Event did not have a 'unsigned.transaction_id' on the GET /rooms/%s/event/%s response", roomID, eventID)
}

must.EqualStr(t, unsignedTxnId.Str, txnId, fmt.Sprintf("Event did not have a 'unsigned.transaction_id' on GET /rooms/%s/event/%s response", eventID, roomID))
hughns marked this conversation as resolved.
Show resolved Hide resolved
}


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

must.EqualStr(t, unsignedTxnId.Str, expectedTxnId, fmt.Sprintf("Event %s in room %s should have a 'unsigned.transaction_id'", eventID, roomID))

return true
hughns marked this conversation as resolved.
Show resolved Hide resolved
}

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 {
unsignedTxnId := r.Get("unsigned.transaction_id")
if unsignedTxnId.Exists() {
t.Fatalf("Event %s in room %s should NOT have a 'unsigned.transaction_id', but it did (%s)", eventID, roomID, unsignedTxnId.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{}{})

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

// 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, txnId))

// Create a second client, inheriting the first device ID.
c2 := deployment.Client(t, "hs1", "")
c2.UserID, c2.AccessToken, _ = c2.LoginUser(t, "alice", "password", client.WithDeviceID(c1.DeviceID))
c2.DeviceID = c1.DeviceID
hughns marked this conversation as resolved.
Show resolved Hide resolved

// 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))
}

// TestTxnIdempotencyScopedToClientSession tests that transaction IDs are scoped to a "client session"
// and behave as expected across multiple clients even if they use the same device ID
func TestTxnIdempotencyScopedToClientSession(t *testing.T) {
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
// Conduit scope transaction IDs to the device ID, not the client session.
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 := "abcdef"
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.LoginUser(t, "alice", "password", client.WithDeviceID(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
must.NotEqualStr(t, eventID2, eventID1, "Expected eventID1 and eventID2 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) {
// Conduit appears to be tracking transaction IDs individually rather than combined with the request URI/room ID
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.
roomID1 := c1.CreateRoom(t, map[string]interface{}{})
roomID2 := c1.CreateRoom(t, map[string]interface{}{})

// choose a transaction ID
txnId := "abc"
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)

must.EqualStr(t, eventID2, eventID1, "Expected eventID1 and eventID2 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)

must.EqualStr(t, eventID3, eventID1, "Expected eventID3 and eventID2 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)

must.NotEqualStr(t, eventID4, eventID3, "Expected eventID4 and eventID3 to be different, but they were not")
}