Skip to content

Commit 0399188

Browse files
MSC3030 Jump to date API endpoint (#178)
MSC3030: matrix-org/matrix-spec-proposals#3030 Synapse implementation: matrix-org/synapse#9445
1 parent 4c45bc0 commit 0399188

File tree

3 files changed

+306
-2
lines changed

3 files changed

+306
-2
lines changed

dockerfiles/synapse/homeserver.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ experimental_features:
112112
msc2716_enabled: true
113113
# server-side support for partial state in /send_join
114114
msc3706_enabled: true
115+
# Enable jump to date endpoint
116+
msc3030_enabled: true
115117

116118
server_notices:
117119
system_mxid_localpart: _server
118120
system_mxid_display_name: "Server Alert"
119121
system_mxid_avatar_url: ""
120-
room_name: "Server Alert"
122+
room_name: "Server Alert"

dockerfiles/synapse/workers-shared.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,11 @@ experimental_features:
6868
msc2716_enabled: true
6969
# Enable spaces support
7070
spaces_enabled: true
71+
# Enable jump to date endpoint
72+
msc3030_enabled: true
7173

7274
server_notices:
7375
system_mxid_localpart: _server
7476
system_mxid_display_name: "Server Alert"
7577
system_mxid_avatar_url: ""
76-
room_name: "Server Alert"
78+
room_name: "Server Alert"

tests/msc3030_test.go

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// +build msc3030
2+
3+
// This file contains tests for a jump to date API endpoint,
4+
// currently experimental feature defined by MSC3030, which you can read here:
5+
// https://github.com/matrix-org/matrix-doc/pull/3030
6+
7+
package tests
8+
9+
import (
10+
"fmt"
11+
"net/url"
12+
"strconv"
13+
"testing"
14+
"time"
15+
16+
"github.com/matrix-org/complement/internal/b"
17+
"github.com/matrix-org/complement/internal/client"
18+
"github.com/tidwall/gjson"
19+
)
20+
21+
func TestJumpToDateEndpoint(t *testing.T) {
22+
deployment := Deploy(t, b.BlueprintFederationTwoLocalOneRemote)
23+
defer deployment.Destroy(t)
24+
25+
// Create the normal user which will send messages in the room
26+
userID := "@alice:hs1"
27+
alice := deployment.Client(t, "hs1", userID)
28+
29+
// Create the federated user which will fetch the messages from a remote homeserver
30+
remoteUserID := "@charlie:hs2"
31+
remoteCharlie := deployment.Client(t, "hs2", remoteUserID)
32+
33+
t.Run("parallel", func(t *testing.T) {
34+
t.Run("should find event after given timestmap", func(t *testing.T) {
35+
t.Parallel()
36+
roomID, eventA, _ := createTestRoom(t, alice)
37+
mustCheckEventisReturnedForTime(t, alice, roomID, eventA.BeforeTimestamp, "f", eventA.EventID)
38+
})
39+
40+
t.Run("should find event before given timestmap", func(t *testing.T) {
41+
t.Parallel()
42+
roomID, _, eventB := createTestRoom(t, alice)
43+
mustCheckEventisReturnedForTime(t, alice, roomID, eventB.AfterTimestamp, "b", eventB.EventID)
44+
})
45+
46+
t.Run("should find nothing before the earliest timestmap", func(t *testing.T) {
47+
t.Parallel()
48+
timeBeforeRoomCreation := time.Now()
49+
roomID, _, _ := createTestRoom(t, alice)
50+
mustCheckEventisReturnedForTime(t, alice, roomID, timeBeforeRoomCreation, "b", "")
51+
})
52+
53+
t.Run("should find nothing after the latest timestmap", func(t *testing.T) {
54+
t.Parallel()
55+
roomID, _, eventB := createTestRoom(t, alice)
56+
mustCheckEventisReturnedForTime(t, alice, roomID, eventB.AfterTimestamp, "f", "")
57+
})
58+
59+
// Just a sanity check that we're not leaking anything from the `/timestamp_to_event` endpoint
60+
t.Run("should not be able to query a private room you are not a member of", func(t *testing.T) {
61+
t.Parallel()
62+
timeBeforeRoomCreation := time.Now()
63+
64+
// Alice will create the private room
65+
roomID := alice.CreateRoom(t, map[string]interface{}{
66+
"preset": "private_chat",
67+
})
68+
69+
// We will use Bob to query the room they're not a member of
70+
nonMemberUser := deployment.Client(t, "hs1", "@bob:hs1")
71+
72+
// Make the `/timestamp_to_event` request from Bob's perspective (non room member)
73+
timestamp := makeTimestampFromTime(timeBeforeRoomCreation)
74+
timestampString := strconv.FormatInt(timestamp, 10)
75+
timestampToEventRes := nonMemberUser.DoFunc(t, "GET", []string{"_matrix", "client", "unstable", "org.matrix.msc3030", "rooms", roomID, "timestamp_to_event"}, client.WithContentType("application/json"), client.WithQueries(url.Values{
76+
"ts": []string{timestampString},
77+
"dir": []string{"f"},
78+
}))
79+
80+
// A random user is not allowed to query for events in a private room
81+
// they're not a member of (forbidden).
82+
if timestampToEventRes.StatusCode != 403 {
83+
t.Fatalf("/timestamp_to_event returned %d HTTP status code but expected %d", timestampToEventRes.StatusCode, 403)
84+
}
85+
})
86+
87+
// Just a sanity check that we're not leaking anything from the `/timestamp_to_event` endpoint
88+
t.Run("should not be able to query a public room you are not a member of", func(t *testing.T) {
89+
t.Parallel()
90+
timeBeforeRoomCreation := time.Now()
91+
92+
// Alice will create the public room
93+
roomID := alice.CreateRoom(t, map[string]interface{}{
94+
"preset": "public_chat",
95+
})
96+
97+
// We will use Bob to query the room they're not a member of
98+
nonMemberUser := deployment.Client(t, "hs1", "@bob:hs1")
99+
100+
// Make the `/timestamp_to_event` request from Bob's perspective (non room member)
101+
timestamp := makeTimestampFromTime(timeBeforeRoomCreation)
102+
timestampString := strconv.FormatInt(timestamp, 10)
103+
timestampToEventRes := nonMemberUser.DoFunc(t, "GET", []string{"_matrix", "client", "unstable", "org.matrix.msc3030", "rooms", roomID, "timestamp_to_event"}, client.WithContentType("application/json"), client.WithQueries(url.Values{
104+
"ts": []string{timestampString},
105+
"dir": []string{"f"},
106+
}))
107+
108+
// A random user is not allowed to query for events in a public room
109+
// they're not a member of (forbidden).
110+
if timestampToEventRes.StatusCode != 403 {
111+
t.Fatalf("/timestamp_to_event returned %d HTTP status code but expected %d", timestampToEventRes.StatusCode, 403)
112+
}
113+
})
114+
115+
t.Run("federation", func(t *testing.T) {
116+
t.Run("looking forwards, should be able to find event that was sent before we joined", func(t *testing.T) {
117+
t.Parallel()
118+
roomID, eventA, _ := createTestRoom(t, alice)
119+
remoteCharlie.JoinRoom(t, roomID, []string{"hs1"})
120+
mustCheckEventisReturnedForTime(t, remoteCharlie, roomID, eventA.BeforeTimestamp, "f", eventA.EventID)
121+
})
122+
123+
t.Run("looking backwards, should be able to find event that was sent before we joined", func(t *testing.T) {
124+
t.Parallel()
125+
roomID, _, eventB := createTestRoom(t, alice)
126+
remoteCharlie.JoinRoom(t, roomID, []string{"hs1"})
127+
mustCheckEventisReturnedForTime(t, remoteCharlie, roomID, eventB.AfterTimestamp, "b", eventB.EventID)
128+
})
129+
})
130+
})
131+
}
132+
133+
type eventTime struct {
134+
EventID string
135+
BeforeTimestamp time.Time
136+
AfterTimestamp time.Time
137+
}
138+
139+
func createTestRoom(t *testing.T, c *client.CSAPI) (roomID string, eventA, eventB *eventTime) {
140+
t.Helper()
141+
142+
roomID = c.CreateRoom(t, map[string]interface{}{
143+
"preset": "public_chat",
144+
})
145+
146+
timeBeforeEventA := time.Now()
147+
eventAID := c.SendEventSynced(t, roomID, b.Event{
148+
Type: "m.room.message",
149+
Content: map[string]interface{}{
150+
"msgtype": "m.text",
151+
"body": "Message A",
152+
},
153+
})
154+
timeAfterEventA := time.Now()
155+
156+
eventBID := c.SendEventSynced(t, roomID, b.Event{
157+
Type: "m.room.message",
158+
Content: map[string]interface{}{
159+
"msgtype": "m.text",
160+
"body": "Message B",
161+
},
162+
})
163+
timeAfterEventB := time.Now()
164+
165+
eventA = &eventTime{EventID: eventAID, BeforeTimestamp: timeBeforeEventA, AfterTimestamp: timeAfterEventA}
166+
eventB = &eventTime{EventID: eventBID, BeforeTimestamp: timeAfterEventA, AfterTimestamp: timeAfterEventB}
167+
168+
return roomID, eventA, eventB
169+
}
170+
171+
// Fetch event from /timestamp_to_event and ensure it matches the expectedEventId
172+
func mustCheckEventisReturnedForTime(t *testing.T, c *client.CSAPI, roomID string, givenTime time.Time, direction string, expectedEventId string) {
173+
t.Helper()
174+
175+
givenTimestamp := makeTimestampFromTime(givenTime)
176+
timestampString := strconv.FormatInt(givenTimestamp, 10)
177+
timestampToEventRes := c.DoFunc(t, "GET", []string{"_matrix", "client", "unstable", "org.matrix.msc3030", "rooms", roomID, "timestamp_to_event"}, client.WithContentType("application/json"), client.WithQueries(url.Values{
178+
"ts": []string{timestampString},
179+
"dir": []string{direction},
180+
}))
181+
timestampToEventResBody := client.ParseJSON(t, timestampToEventRes)
182+
183+
// Only allow a 200 response meaning we found an event or a 404 meaning we didn't.
184+
// Other status codes will throw and assumed to be application errors.
185+
actualEventId := ""
186+
if timestampToEventRes.StatusCode == 200 {
187+
actualEventId = client.GetJSONFieldStr(t, timestampToEventResBody, "event_id")
188+
} else if timestampToEventRes.StatusCode != 404 {
189+
t.Fatalf("mustCheckEventisReturnedForTime: /timestamp_to_event request failed with status=%d", timestampToEventRes.StatusCode)
190+
}
191+
192+
if actualEventId != expectedEventId {
193+
debugMessageList := getDebugMessageListFromMessagesResponse(t, c, roomID, expectedEventId, actualEventId, givenTimestamp)
194+
t.Fatalf(
195+
"Want %s given %s but got %s\n%s",
196+
decorateStringWithAnsiColor(expectedEventId, AnsiColorGreen),
197+
decorateStringWithAnsiColor(timestampString, AnsiColorYellow),
198+
decorateStringWithAnsiColor(actualEventId, AnsiColorRed),
199+
debugMessageList,
200+
)
201+
}
202+
}
203+
204+
func getDebugMessageListFromMessagesResponse(t *testing.T, c *client.CSAPI, roomID string, expectedEventId string, actualEventId string, givenTimestamp int64) string {
205+
t.Helper()
206+
207+
messagesRes := c.MustDoFunc(t, "GET", []string{"_matrix", "client", "r0", "rooms", roomID, "messages"}, client.WithContentType("application/json"), client.WithQueries(url.Values{
208+
// The events returned will be from the newest -> oldest since we're going backwards
209+
"dir": []string{"b"},
210+
"limit": []string{"100"},
211+
}))
212+
messsageResBody := client.ParseJSON(t, messagesRes)
213+
214+
wantKey := "chunk"
215+
keyRes := gjson.GetBytes(messsageResBody, wantKey)
216+
if !keyRes.Exists() {
217+
t.Fatalf("missing key '%s'", wantKey)
218+
}
219+
if !keyRes.IsArray() {
220+
t.Fatalf("key '%s' is not an array (was %s)", wantKey, keyRes.Type)
221+
}
222+
223+
// Make the events go from oldest-in-time -> newest-in-time
224+
events := reverseGjsonArray(keyRes.Array())
225+
if len(events) == 0 {
226+
t.Fatalf(
227+
"getDebugMessageListFromMessagesResponse found no messages in the room(%s).",
228+
roomID,
229+
)
230+
}
231+
232+
// We need some padding for some lines to make them all align with the label.
233+
// Pad this out so it equals whatever the longest label is.
234+
paddingString := " "
235+
236+
resultantString := fmt.Sprintf("%s-- oldest events --\n", paddingString)
237+
238+
givenTimestampAlreadyInserted := false
239+
givenTimestampMarker := decorateStringWithAnsiColor(fmt.Sprintf("%s-- givenTimestamp=%s --\n", paddingString, strconv.FormatInt(givenTimestamp, 10)), AnsiColorYellow)
240+
241+
// We're iterating over the events from oldest-in-time -> newest-in-time
242+
for _, ev := range events {
243+
// As we go, keep checking whether the givenTimestamp is
244+
// older(before-in-time) than the current event and insert a timestamp
245+
// marker as soon as we find the spot
246+
if givenTimestamp < ev.Get("origin_server_ts").Int() && !givenTimestampAlreadyInserted {
247+
resultantString += givenTimestampMarker
248+
givenTimestampAlreadyInserted = true
249+
}
250+
251+
eventID := ev.Get("event_id").String()
252+
eventIDString := eventID
253+
labelString := paddingString
254+
if eventID == expectedEventId {
255+
eventIDString = decorateStringWithAnsiColor(eventID, AnsiColorGreen)
256+
labelString = "(want) "
257+
} else if eventID == actualEventId {
258+
eventIDString = decorateStringWithAnsiColor(eventID, AnsiColorRed)
259+
labelString = " (got) "
260+
}
261+
262+
resultantString += fmt.Sprintf(
263+
"%s%s (%s) - %s\n",
264+
labelString,
265+
eventIDString,
266+
strconv.FormatInt(ev.Get("origin_server_ts").Int(), 10),
267+
ev.Get("type").String(),
268+
)
269+
}
270+
271+
// The givenTimestamp could be newer(after-in-time) than any of the other events
272+
if givenTimestamp > events[len(events)-1].Get("origin_server_ts").Int() && !givenTimestampAlreadyInserted {
273+
resultantString += givenTimestampMarker
274+
givenTimestampAlreadyInserted = true
275+
}
276+
277+
resultantString += fmt.Sprintf("%s-- newest events --\n", paddingString)
278+
279+
return resultantString
280+
}
281+
282+
func makeTimestampFromTime(t time.Time) int64 {
283+
return t.UnixNano() / int64(time.Millisecond)
284+
}
285+
286+
const AnsiColorRed string = "31"
287+
const AnsiColorGreen string = "32"
288+
const AnsiColorYellow string = "33"
289+
290+
func decorateStringWithAnsiColor(inputString, decorationColor string) string {
291+
return fmt.Sprintf("\033[%sm%s\033[0m", decorationColor, inputString)
292+
}
293+
294+
func reverseGjsonArray(in []gjson.Result) []gjson.Result {
295+
out := make([]gjson.Result, len(in))
296+
for i := 0; i < len(in); i++ {
297+
out[i] = in[len(in)-i-1]
298+
}
299+
return out
300+
}

0 commit comments

Comments
 (0)