diff --git a/README.md b/README.md index ae4f894..d47a568 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ There is an exhaustive set of tests that this repository aims to exercise which Membership ACLs: - [x] Happy case Alice and Bob in an encrypted room can send and receive encrypted messages, and decrypt them all. - [x] Bob can see messages when he was invited but not joined to the room. Subsequent messages are also decryptable. -- [ ] In a public, `shared` history visibility room, a new user Bob cannot decrypt earlier messages prior to his join, despite being able to see the events. Subsequent messages are decryptable. +- [x] In a public, `shared` history visibility room, a new user Bob cannot decrypt earlier messages prior to his join, despite being able to see the events. Subsequent messages are decryptable. - [ ] Bob leaves the room. Some messages are sent. Bob rejoins and cannot decrypt the messages sent whilst he was gone (ensuring we cycle keys). Repeat this again with a device instead of a user (so 2x device, 1 remains always in the room, 1 then logs out -> messages sent -> logs in again). - [ ] Alice invites Bob, Bob changes their device, then Bob joins. Bob should be able to see Alice's message. diff --git a/internal/api/js.go b/internal/api/js.go index dad65ca..eea6afd 100644 --- a/internal/api/js.go +++ b/internal/api/js.go @@ -162,9 +162,9 @@ func NewJSClient(t *testing.T, opts ClientCreationOpts) (Client, error) { // any events need to log the control string so we get notified chrome.MustExecute(t, ctx, fmt.Sprintf(`window.__client.on("Event.decrypted", function(event) { - if (event.getType() !== "m.room.message") { - return; // only use messages - } + console.log("%s"+event.getRoomId()+"||"+JSON.stringify(event.getEffectiveEvent())); + });`, CONSOLE_LOG_CONTROL_STRING)) + chrome.MustExecute(t, ctx, fmt.Sprintf(`window.__client.on("event", function(event) { console.log("%s"+event.getRoomId()+"||"+JSON.stringify(event.getEffectiveEvent())); });`, CONSOLE_LOG_CONTROL_STRING)) @@ -181,10 +181,6 @@ func NewJSClient(t *testing.T, opts ClientCreationOpts) (Client, error) { func (c *JSClient) Close(t *testing.T) { c.cancel() c.listeners = make(map[int32]func(roomID string, ev Event)) - if t.Failed() { - // print logs for this test - - } } func (c *JSClient) UserID() string { @@ -192,6 +188,7 @@ func (c *JSClient) UserID() string { } func (c *JSClient) MustGetEvent(t *testing.T, roomID, eventID string) Event { + t.Helper() // serialised output (if encrypted): // { // encrypted: { event } @@ -199,12 +196,13 @@ func (c *JSClient) MustGetEvent(t *testing.T, roomID, eventID string) Event { // } // else just returns { event } evSerialised := chrome.MustExecuteInto[string](t, c.ctx, fmt.Sprintf(` - JSON.stringify(window.__client.getRoom("%s")?.getLiveTimeline()?.getEvents().filter((ev) => { + JSON.stringify(window.__client.getRoom("%s")?.getLiveTimeline()?.getEvents().filter((ev, i) => { + console.log("MustGetEvent["+i+"] => " + ev.getId()+ " " + JSON.stringify(ev.toJSON())); return ev.getId() === "%s"; })[0].toJSON()); `, roomID, eventID)) if !gjson.Valid(evSerialised) { - fatalf(t, "MustGetEvent(%s, %s): invalid event, got %s", roomID, eventID, evSerialised) + fatalf(t, "MustGetEvent(%s, %s) %s (js): invalid event, got %s", roomID, eventID, c.userID, evSerialised) } result := gjson.Parse(evSerialised) decryptedEvent := result.Get("decrypted") @@ -232,6 +230,7 @@ func (c *JSClient) MustGetEvent(t *testing.T, roomID, eventID string) Event { // StartSyncing to begin syncing from sync v2 / sliding sync. // Tests should call stopSyncing() at the end of the test. func (c *JSClient) StartSyncing(t *testing.T) (stopSyncing func()) { + t.Helper() t.Logf("%s is starting to sync", c.userID) chrome.MustExecute(t, c.ctx, fmt.Sprintf(` var fn; @@ -266,6 +265,7 @@ func (c *JSClient) StartSyncing(t *testing.T) (stopSyncing func()) { // IsRoomEncrypted returns true if the room is encrypted. May return an error e.g if you // provide a bogus room ID. func (c *JSClient) IsRoomEncrypted(t *testing.T, roomID string) (bool, error) { + t.Helper() isEncrypted, err := chrome.ExecuteInto[bool]( t, c.ctx, fmt.Sprintf(`window.__client.isRoomEncrypted("%s")`, roomID), ) @@ -278,6 +278,7 @@ func (c *JSClient) IsRoomEncrypted(t *testing.T, roomID string) (bool, error) { // SendMessage sends the given text as an m.room.message with msgtype:m.text into the given // room. func (c *JSClient) SendMessage(t *testing.T, roomID, text string) (eventID string) { + t.Helper() res, err := chrome.AwaitExecuteInto[map[string]interface{}](t, c.ctx, fmt.Sprintf(`window.__client.sendMessage("%s", { "msgtype": "m.text", "body": "%s" @@ -287,12 +288,14 @@ func (c *JSClient) SendMessage(t *testing.T, roomID, text string) (eventID strin } func (c *JSClient) MustBackpaginate(t *testing.T, roomID string, count int) { + t.Helper() chrome.MustAwaitExecute(t, c.ctx, fmt.Sprintf( `window.__client.scrollback(window.__client.getRoom("%s"), %d);`, roomID, count, )) } func (c *JSClient) WaitUntilEventInRoom(t *testing.T, roomID string, checker func(e Event) bool) Waiter { + t.Helper() return &jsTimelineWaiter{ roomID: roomID, checker: checker, @@ -326,6 +329,7 @@ type jsTimelineWaiter struct { } func (w *jsTimelineWaiter) Wait(t *testing.T, s time.Duration) { + t.Helper() updates := make(chan bool, 3) cancel := w.client.listenForUpdates(func(roomID string, ev Event) { if w.roomID != roomID { @@ -349,11 +353,11 @@ func (w *jsTimelineWaiter) Wait(t *testing.T, s time.Duration) { for { timeLeft := s - time.Since(start) if timeLeft <= 0 { - fatalf(t, "%s: Wait[%s]: timed out", w.client.userID, w.roomID) + fatalf(t, "%s (js): Wait[%s]: timed out", w.client.userID, w.roomID) } select { case <-time.After(timeLeft): - fatalf(t, "%s: Wait[%s]: timed out", w.client.userID, w.roomID) + fatalf(t, "%s (js): Wait[%s]: timed out", w.client.userID, w.roomID) case <-updates: return } diff --git a/internal/api/rust.go b/internal/api/rust.go index e22d50b..ff1b9dc 100644 --- a/internal/api/rust.go +++ b/internal/api/rust.go @@ -26,9 +26,9 @@ func init() { var zero uint32 type RustRoomInfo struct { - attachedListener bool - room *matrix_sdk_ffi.Room - timeline []*Event + stream *matrix_sdk_ffi.TaskHandle + room *matrix_sdk_ffi.Room + timeline []*Event } type RustClient struct { @@ -73,14 +73,15 @@ func (c *RustClient) Close(t *testing.T) { } func (c *RustClient) MustGetEvent(t *testing.T, roomID, eventID string) Event { + t.Helper() room := c.findRoom(t, roomID) timelineItem, err := room.GetEventTimelineItemByEventId(eventID) if err != nil { - fatalf(t, "MustGetEvent(%s, %s): %s", roomID, eventID, err) + fatalf(t, "MustGetEvent(rust) %s (%s, %s): %s", c.userID, roomID, eventID, err) } ev := eventTimelineItemToEvent(timelineItem) if ev == nil { - fatalf(t, "MustGetEvent(%s, %s): found timeline item but failed to convert it to an Event", roomID, eventID) + fatalf(t, "MustGetEvent(rust) %s (%s, %s): found timeline item but failed to convert it to an Event", c.userID, roomID, eventID) } return *ev } @@ -179,7 +180,7 @@ func (c *RustClient) SendMessage(t *testing.T, roomID, text string) (eventID str r.Send(matrix_sdk_ffi.MessageEventContentFromHtml(text, text)) select { case <-time.After(5 * time.Second): - fatalf(t, "SendMessage: timed out after 5s") + fatalf(t, "SendMessage(rust) %s: timed out after 5s", c.userID) case <-ch: return } @@ -212,6 +213,7 @@ func (c *RustClient) findRoomInMap(roomID string) *matrix_sdk_ffi.Room { // findRoom returns the room, waiting up to 5s for it to appear func (c *RustClient) findRoom(t *testing.T, roomID string) *matrix_sdk_ffi.Room { + t.Helper() room := c.findRoomInMap(roomID) if room != nil { return room @@ -258,11 +260,12 @@ func (c *RustClient) Logf(t *testing.T, format string, args ...interface{}) { } func (c *RustClient) ensureListening(t *testing.T, roomID string) *matrix_sdk_ffi.Room { + t.Helper() r := c.findRoom(t, roomID) must.NotEqual(t, r, nil, fmt.Sprintf("room %s does not exist", roomID)) info := c.rooms[roomID] - if info.attachedListener { + if info.stream != nil { return r } @@ -270,6 +273,7 @@ func (c *RustClient) ensureListening(t *testing.T, roomID string) *matrix_sdk_ff // we need a timeline listener before we can send messages result := r.AddTimelineListener(&timelineListener{fn: func(diff []*matrix_sdk_ffi.TimelineDiff) { timeline := c.rooms[roomID].timeline + var newEvents []*Event c.Logf(t, "[%s]AddTimelineListener[%s] TimelineDiff len=%d", c.userID, roomID, len(diff)) for _, d := range diff { switch d.Change() { @@ -285,6 +289,7 @@ func (c *RustClient) ensureListening(t *testing.T, roomID string) *matrix_sdk_ff } timeline = slices.Insert(timeline, i, timelineItemToEvent(insertData.Item)) fmt.Printf("[%s]_______ INSERT %+v\n", c.userID, timeline[i]) + newEvents = append(newEvents, timeline[i]) case matrix_sdk_ffi.TimelineChangeAppend: appendItems := d.Append() if appendItems == nil { @@ -294,6 +299,7 @@ func (c *RustClient) ensureListening(t *testing.T, roomID string) *matrix_sdk_ff ev := timelineItemToEvent(item) timeline = append(timeline, ev) fmt.Printf("[%s]_______ APPEND %+v\n", c.userID, ev) + newEvents = append(newEvents, ev) } case matrix_sdk_ffi.TimelineChangePushBack: // append but 1 element pbData := d.PushBack() @@ -303,6 +309,7 @@ func (c *RustClient) ensureListening(t *testing.T, roomID string) *matrix_sdk_ff ev := timelineItemToEvent(*pbData) timeline = append(timeline, ev) fmt.Printf("[%s]_______ PUSH BACK %+v\n", c.userID, ev) + newEvents = append(newEvents, ev) case matrix_sdk_ffi.TimelineChangeSet: setData := d.Set() if setData == nil { @@ -315,6 +322,7 @@ func (c *RustClient) ensureListening(t *testing.T, roomID string) *matrix_sdk_ff } timeline[i] = timelineItemToEvent(setData.Item) fmt.Printf("[%s]_______ SET %+v\n", c.userID, timeline[i]) + newEvents = append(newEvents, timeline[i]) default: t.Logf("Unhandled TimelineDiff change %v", d.Change()) } @@ -323,11 +331,15 @@ func (c *RustClient) ensureListening(t *testing.T, roomID string) *matrix_sdk_ff for _, l := range c.listeners { l(roomID) } + for _, e := range newEvents { + c.Logf(t, "TimelineDiff change: %+v", e) + } }}) events := make([]*Event, len(result.Items)) for i := range result.Items { events[i] = timelineItemToEvent(result.Items[i]) } + c.rooms[roomID].stream = result.ItemsStream c.rooms[roomID].timeline = events c.Logf(t, "[%s]AddTimelineListener[%s] result.Items len=%d", c.userID, roomID, len(result.Items)) if len(events) > 0 { @@ -335,7 +347,6 @@ func (c *RustClient) ensureListening(t *testing.T, roomID string) *matrix_sdk_ff l(roomID) } } - info.attachedListener = true return r } @@ -381,12 +392,15 @@ func (w *timelineWaiter) Wait(t *testing.T, s time.Duration) { return } - updates := make(chan bool, 10) + updates := make(chan bool, 3) cancel := w.client.listenForUpdates(func(roomID string) { if w.roomID != roomID { return } - updates <- true + if !checkForEvent() { + return + } + close(updates) }) defer cancel() @@ -395,15 +409,13 @@ func (w *timelineWaiter) Wait(t *testing.T, s time.Duration) { for { timeLeft := s - time.Since(start) if timeLeft <= 0 { - fatalf(t, "%s: Wait[%s]: timed out", w.client.userID, w.roomID) + fatalf(t, "%s (rust): Wait[%s]: timed out", w.client.userID, w.roomID) } select { case <-time.After(timeLeft): - fatalf(t, "%s: Wait[%s]: timed out", w.client.userID, w.roomID) + fatalf(t, "%s (rust): Wait[%s]: timed out", w.client.userID, w.roomID) case <-updates: - if checkForEvent() { - return - } + return } } } diff --git a/tests/membership_acls_test.go b/tests/membership_acls_test.go index f08dddc..daf84a3 100644 --- a/tests/membership_acls_test.go +++ b/tests/membership_acls_test.go @@ -245,3 +245,92 @@ func TestBobCanSeeButNotDecryptHistoryInPublicRoom(t *testing.T) { must.Equal(t, ev.FailedToDecrypt, true, "message not marked as failed to decrypt") }) } + +// Bob leaves the room. Some messages are sent. Bob rejoins and cannot decrypt the messages sent whilst he was gone (ensuring we cycle keys). +func TestOnRejoinBobCanSeeButNotDecryptHistoryInPublicRoom(t *testing.T) { + ClientTypeMatrix(t, func(t *testing.T, clientTypeA, clientTypeB api.ClientType) { + // Setup Code + // ---------- + deployment := Deploy(t) + // pre-register alice and bob + csapiAlice := deployment.Register(t, "hs1", helpers.RegistrationOpts{ + LocalpartSuffix: "alice", + Password: "complement-crypto-password", + }) + csapiBob := deployment.Register(t, "hs1", helpers.RegistrationOpts{ + LocalpartSuffix: "bob", + Password: "complement-crypto-password", + }) + roomID := csapiAlice.MustCreateRoom(t, map[string]interface{}{ + "name": "TestOnRejoinBobCanSeeButNotDecryptHistoryInPublicRoom", + "preset": "public_chat", // shared history visibility + "initial_state": []map[string]interface{}{ + { + "type": "m.room.encryption", + "state_key": "", + "content": map[string]interface{}{ + "algorithm": "m.megolm.v1.aes-sha2", + }, + }, + }, + }) + csapiBob.MustJoinRoom(t, roomID, []string{"hs1"}) + ss := deployment.SlidingSyncURL(t) + + // SDK testing below + // ----------------- + + // login both clients first, so OTKs etc are uploaded. + alice := MustLoginClient(t, clientTypeA, api.FromComplementClient(csapiAlice, "complement-crypto-password"), ss) + defer alice.Close(t) + bob := MustLoginClient(t, clientTypeB, api.FromComplementClient(csapiBob, "complement-crypto-password"), ss) + defer bob.Close(t) + + // Alice and Bob start syncing. Both are in the same room + aliceStopSyncing := alice.StartSyncing(t) + defer aliceStopSyncing() + bobStopSyncing := bob.StartSyncing(t) + defer bobStopSyncing() + + // Alice sends a message which Bob should be able to decrypt. + bothJoinedBody := "Alice and Bob in a room" + waiter := bob.WaitUntilEventInRoom(t, roomID, api.CheckEventHasBody(bothJoinedBody)) + evID := alice.SendMessage(t, roomID, bothJoinedBody) + t.Logf("bob (%s) waiting for event %s", bob.Type(), evID) + waiter.Wait(t, 5*time.Second) + + // now bob leaves the room, wait for alice to see it + waiter = alice.WaitUntilEventInRoom(t, roomID, api.CheckEventHasMembership(bob.UserID(), "leave")) + csapiBob.MustLeaveRoom(t, roomID) + waiter.Wait(t, 5*time.Second) + + // now alice sends another message, which should use a key that bob does not have. Wait for the remote echo to come back. + onlyAliceBody := "Only me on my lonesome" + waiter = alice.WaitUntilEventInRoom(t, roomID, api.CheckEventHasBody(onlyAliceBody)) + evID = alice.SendMessage(t, roomID, onlyAliceBody) + t.Logf("alice (%s) waiting for event %s", alice.Type(), evID) + waiter.Wait(t, 5*time.Second) + + // now bob rejoins the room, wait until he sees it. + csapiBob.MustJoinRoom(t, roomID, []string{"hs1"}) + waiter = bob.WaitUntilEventInRoom(t, roomID, api.CheckEventHasMembership(bob.UserID(), "join")) + waiter.Wait(t, 5*time.Second) + // this is required for some reason else tests fail + time.Sleep(time.Second) + + // bob hits scrollback and should see but not be able to decrypt the message + bob.MustBackpaginate(t, roomID, 5) + ev := bob.MustGetEvent(t, roomID, evID) + must.NotEqual(t, ev.Text, onlyAliceBody, "bob was able to decrypt a message from before he was joined") + must.Equal(t, ev.FailedToDecrypt, true, "message not marked as failed to decrypt") + + /* TODO: needs client changes + time.Sleep(time.Second) // let alice realise bob is back in the room + // bob should be able to decrypt subsequent messages + bothJoinedBody = "Alice and Bob in a room again" + evID = alice.SendMessage(t, roomID, bothJoinedBody) + time.Sleep(time.Second) // TODO: use a Waiter; currently this is broken as it seems like listeners get detached on leave? + ev = bob.MustGetEvent(t, roomID, evID) + must.Equal(t, ev.Text, bothJoinedBody, "event was not decrypted correctly") */ + }) +}