From 7bc4c67bd007f3bca52152c02f7bd4638602e91a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 24 Mar 2023 16:38:26 +0100 Subject: [PATCH 1/7] Tests for MSC3970 --- tests/msc3970_test.go | 109 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/msc3970_test.go diff --git a/tests/msc3970_test.go b/tests/msc3970_test.go new file mode 100644 index 00000000..548ac1d6 --- /dev/null +++ b/tests/msc3970_test.go @@ -0,0 +1,109 @@ +//go:build msc3970 +// +build msc3970 + +// This files contains tests for MSC3970, which changes the scope +// of transaction IDs to be per-device instead of per-access token. +// https://github.com/matrix-org/matrix-spec-proposals/pull/3970 + +package 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/tidwall/gjson" +) + +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 + } + + return false + }) +} + +// TestTxnScopeOnLocalEchoMSC3970 checks that the transaction IDs are scoped to the device, +// and not just the access token, as per MSC3970. +func TestTxnScopeOnLocalEchoMSC3970(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. + 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 + + // When syncing, we should find the event and it should *not* have a transaction ID on the second client. + c2.MustSyncUntil(t, client.SyncReq{}, mustHaveTransactionID(t, roomID, eventID, txnId)) +} + +// TestTxnIdempotencyScopedToClientDeviceMSC3970 tests that transaction IDs are scoped to a device +func TestTxnIdempotencyScopedToDeviceMSC3970(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. + 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.EqualStr(t, eventID2, eventID1, "Expected eventID1 and eventID2 to be equal from two clients sharing the same device ID") +} From dd986d8d5a29cd7f30bcb77c5bc1cd0b7d2adc58 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Sat, 4 Mar 2023 08:40:20 +0000 Subject: [PATCH 2/7] Test for transaction ID semantics --- internal/client/client.go | 1 - tests/csapi/txnid_test.go | 169 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/internal/client/client.go b/internal/client/client.go index 9dde9516..a9a7ebb8 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -264,7 +264,6 @@ 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, strconv.Itoa(txnID)) } diff --git a/tests/csapi/txnid_test.go b/tests/csapi/txnid_test.go index 01c055dd..1a104811 100644 --- a/tests/csapi/txnid_test.go +++ b/tests/csapi/txnid_test.go @@ -213,3 +213,172 @@ func TestTxnIdempotency(t *testing.T) { must.NotEqualStr(t, eventID4, eventID3, "Expected eventID4 and eventID3 to be different, but they were not") } + + +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)) +} + +// 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) { + // 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 := 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") + } +} From d761b75260a530425e7d04d564c54342771e51b0 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 6 Mar 2023 16:52:44 +0000 Subject: [PATCH 3/7] Update internal/client/client.go Co-authored-by: kegsay --- internal/client/client.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/client/client.go b/internal/client/client.go index a9a7ebb8..9dde9516 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -264,6 +264,7 @@ 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, strconv.Itoa(txnID)) } From cf2fab03d6235ca28c743d26a8b8a30bda4742ee Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 9 Mar 2023 12:55:47 -0500 Subject: [PATCH 4/7] Incorporate review feedback --- tests/csapi/txnid_test.go | 43 +++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/tests/csapi/txnid_test.go b/tests/csapi/txnid_test.go index 1a104811..efee8fc1 100644 --- a/tests/csapi/txnid_test.go +++ b/tests/csapi/txnid_test.go @@ -215,11 +215,17 @@ func TestTxnIdempotency(t *testing.T) { } -func mustHaveTransactionID(t *testing.T, roomID, eventID string) client.SyncCheckOpt { +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 { 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) + t.Fatalf("Event %s in room %s should have a 'unsigned.transaction_id', but it did not", eventID, roomID) + } + + txnIdFromSync := r.Get("unsigned.transaction_id").Str + + if txnIdFromSync != expectedTxnId { + t.Fatalf("Event %s in room %s should have a 'unsigned.transaction_id' of %s but found %s", eventID, roomID, expectedTxnId, txnIdFromSync) } return true @@ -234,7 +240,7 @@ func mustNotHaveTransactionID(t *testing.T, roomID, eventID string) client.SyncC 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) + t.Fatalf("Event %s in room %s should NOT have a 'unsigned.transaction_id', but it did (%s)", eventID, roomID, res.Str) } return true @@ -262,21 +268,22 @@ func TestTxnScopeOnLocalEcho(t *testing.T) { // 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.SendEventUnsynced(t, roomID, b.Event{ + 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)) + 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.LoginUserWithDeviceID(t, "alice", "password", c1.DeviceID) + c2.UserID, c2.AccessToken, _ = c2.LoginUser(t, "alice", "password", client.WithDeviceID(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. @@ -301,7 +308,7 @@ func TestTxnIdempotencyScopedToClientSession(t *testing.T) { // Create a room where we can send events. roomID := c1.CreateRoom(t, map[string]interface{}{}) - txnId := 1 + txnId := "abcdef" event := b.Event{ Type: "m.room.message", Content: map[string]interface{}{ @@ -314,16 +321,14 @@ func TestTxnIdempotencyScopedToClientSession(t *testing.T) { // 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.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 - if eventID1 == eventID2 { - t.Fatalf("Expected event IDs to be different from two clients sharing the same device ID") - } + 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 @@ -342,7 +347,7 @@ func TestTxnIdempotency(t *testing.T) { roomID2 := c1.CreateRoom(t, map[string]interface{}{}) // choose a transaction ID - txnId := 1 + txnId := "abc" event1 := b.Event{ Type: "m.room.message", Content: map[string]interface{}{ @@ -364,21 +369,15 @@ func TestTxnIdempotency(t *testing.T) { // 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") - } + 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) - if eventID1 != eventID3 { - t.Fatalf("Expected event IDs to be the same even with different content, but they were not") - } + 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) - if eventID4 == eventID3 { - t.Fatalf("Expected event IDs to be the different, but they were not") - } + must.NotEqualStr(t, eventID4, eventID3, "Expected eventID4 and eventID3 to be different, but they were not") } From 3ee80b9f15b7fb463f19882b35906da98d5cb8f0 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 9 Mar 2023 13:21:26 -0500 Subject: [PATCH 5/7] Refactor for legibility --- tests/csapi/txnid_test.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/csapi/txnid_test.go b/tests/csapi/txnid_test.go index efee8fc1..f0ba934d 100644 --- a/tests/csapi/txnid_test.go +++ b/tests/csapi/txnid_test.go @@ -218,15 +218,12 @@ func TestTxnIdempotency(t *testing.T) { 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 { - if !r.Get("unsigned.transaction_id").Exists() { + 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) } - txnIdFromSync := r.Get("unsigned.transaction_id").Str - - if txnIdFromSync != expectedTxnId { - t.Fatalf("Event %s in room %s should have a 'unsigned.transaction_id' of %s but found %s", eventID, roomID, expectedTxnId, txnIdFromSync) - } + must.EqualStr(t, unsignedTxnId.Str, expectedTxnId, fmt.Sprintf("Event %s in room %s should have a 'unsigned.transaction_id'", eventID, roomID)) return true } @@ -238,9 +235,9 @@ func mustHaveTransactionID(t *testing.T, roomID, eventID, expectedTxnId string) 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 'unsigned.transaction_id', but it did (%s)", eventID, roomID, res.Str) + 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 From 5aa949291d20a325fc5c2ea5bb0c55205de763ba Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 9 Mar 2023 13:49:50 -0500 Subject: [PATCH 6/7] Clarify that conduit doesn't pass idempotency tests --- tests/csapi/txnid_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/csapi/txnid_test.go b/tests/csapi/txnid_test.go index f0ba934d..69f1108d 100644 --- a/tests/csapi/txnid_test.go +++ b/tests/csapi/txnid_test.go @@ -330,6 +330,9 @@ func TestTxnIdempotencyScopedToClientSession(t *testing.T) { // 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) From f298b613469e548b2ef6a7253329f5ffbc4b2d16 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Tue, 18 Apr 2023 18:25:51 +0100 Subject: [PATCH 7/7] Fix rebase Co-Authored-By: kegsay --- tests/csapi/txnid_test.go | 168 -------------------------------------- 1 file changed, 168 deletions(-) diff --git a/tests/csapi/txnid_test.go b/tests/csapi/txnid_test.go index 69f1108d..01c055dd 100644 --- a/tests/csapi/txnid_test.go +++ b/tests/csapi/txnid_test.go @@ -213,171 +213,3 @@ func TestTxnIdempotency(t *testing.T) { must.NotEqualStr(t, eventID4, eventID3, "Expected eventID4 and eventID3 to be different, but they were not") } - - -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 - } - - 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 - - // 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) { - // 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") -}